diff --git a/app/src/components/settings/panels/CronJobsPanel.test.tsx b/app/src/components/settings/panels/CronJobsPanel.test.tsx index d4ccc17e4c..8338b4a6ed 100644 --- a/app/src/components/settings/panels/CronJobsPanel.test.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.test.tsx @@ -13,6 +13,17 @@ vi.mock('../hooks/useSettingsNavigation', () => ({ useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), })); +// ── Mock Redux store hooks ────────────────────────────────────────────── +// The panel dispatches loadAgentProfiles + reads the agentProfile slice to +// feed the attribution picker / job-list labels. These tests render the panel +// without a Provider, so stub the hooks (profile UI is covered by the +// CronJobFormModal / CoreJobList component tests). +const noopDispatch = vi.fn(); +vi.mock('../../../store/hooks', () => ({ + useAppDispatch: () => noopDispatch, + useAppSelector: () => [], +})); + // ── Mock SettingsHeader ───────────────────────────────────────────────── vi.mock('../components/SettingsHeader', () => ({ default: ({ title }: { title: string }) =>
{title}
, diff --git a/app/src/components/settings/panels/CronJobsPanel.tsx b/app/src/components/settings/panels/CronJobsPanel.tsx index a10872f74c..0c5d0048fa 100644 --- a/app/src/components/settings/panels/CronJobsPanel.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.tsx @@ -2,6 +2,8 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; +import { loadAgentProfiles, selectAgentProfiles } from '../../../store/agentProfileSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { type CoreCronJob, type CoreCronRun, @@ -23,6 +25,8 @@ const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills') const CronJobsPanel = () => { const { t } = useT(); + const dispatch = useAppDispatch(); + const profiles = useAppSelector(selectAgentProfiles); const formatCronError = useCallback( (key: string, message: string) => t(key).replace('{message}', message), [t] @@ -70,6 +74,11 @@ const CronJobsPanel = () => { void loadCoreCronJobsOnly(); }, [loadCoreCronJobsOnly]); + // Populate the agent-profile attribution picker + job-list labels. + useEffect(() => { + void dispatch(loadAgentProfiles()); + }, [dispatch]); + const toggleCoreJob = async (job: CoreCronJob) => { const key = `core-toggle:${job.id}`; setCoreBusyKey(key); @@ -207,6 +216,7 @@ const CronJobsPanel = () => { void toggleCoreJob(job)} @@ -235,6 +245,7 @@ const CronJobsPanel = () => { key="cron-form-create" mode="create" open={true} + profiles={profiles} onClose={() => setFormOpen(false)} onCreate={params => handleCreate(params)} onUpdate={handleUpdate} @@ -248,6 +259,7 @@ const CronJobsPanel = () => { mode="edit" job={editingJob} open={true} + profiles={profiles} onClose={() => setEditingJob(null)} onCreate={handleCreate} onUpdate={handleUpdate} diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx index c50b1ba208..5c86642784 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.test.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -140,4 +140,62 @@ describe('ProfileEditorPage', () => { await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); expect(mockUpsert.mock.calls[0][0].includeAgentConversations).toBe(false); }); + + it('defaults the dedicated memory/workspace toggles to off and dispatches them true when flipped', async () => { + renderAt('/settings/profiles/new'); + const dedicatedMemory = screen.getByLabelText('Dedicated memory'); + const dedicatedWorkspace = screen.getByLabelText('Dedicated workspace'); + expect(dedicatedMemory).toHaveAttribute('aria-checked', 'false'); + expect(dedicatedWorkspace).toHaveAttribute('aria-checked', 'false'); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Isolated' } }); + fireEvent.click(dedicatedMemory); + fireEvent.click(dedicatedWorkspace); + expect(screen.getByLabelText('Dedicated memory')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByLabelText('Dedicated workspace')).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); + expect(mockUpsert.mock.calls[0][0].dedicatedMemory).toBe(true); + expect(mockUpsert.mock.calls[0][0].dedicatedWorkspace).toBe(true); + }); + + it('edit mode hydrates the dedicated toggles and shows the resolved read-only paths', () => { + renderAt('/settings/profiles/edit/writer', [ + profile({ + id: 'writer', + name: 'Writer', + dedicatedMemory: true, + dedicatedWorkspace: true, + soulMdFile: '/workspace/personalities/writer/SOUL.md', + workspaceDir: '/action/profiles/writer', + }), + ]); + expect(screen.getByLabelText('Dedicated memory')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByLabelText('Dedicated workspace')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('/workspace/personalities/writer/SOUL.md')).toBeInTheDocument(); + expect(screen.getByText('/action/profiles/writer')).toBeInTheDocument(); + }); + + it('hides the resolved read-only path rows when the profile has none', () => { + renderAt('/settings/profiles/edit/writer', [profile({ id: 'writer' })]); + expect(screen.queryByText('SOUL.md file')).not.toBeInTheDocument(); + expect(screen.queryByText('Workspace directory')).not.toBeInTheDocument(); + expect(screen.queryByText('Skills directory')).not.toBeInTheDocument(); + }); + + it('shows the resolved skills directory path and hint when present', () => { + renderAt('/settings/profiles/edit/writer', [ + profile({ + id: 'writer', + name: 'Writer', + skillsDir: '/workspace/personalities/writer/skills', + }), + ]); + expect(screen.getByText('Skills directory')).toBeInTheDocument(); + expect(screen.getByText('/workspace/personalities/writer/skills')).toBeInTheDocument(); + expect( + screen.getByText('SKILL.md files placed here are private to this profile.') + ).toBeInTheDocument(); + }); }); diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx index 200c85772f..e7cde2d684 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -77,6 +77,8 @@ const ProfileEditorPage = () => { const [composioIntegrations, setComposioIntegrations] = useState(null); const [allowedSkills, setAllowedSkills] = useState(null); const [allowedMcpServers, setAllowedMcpServers] = useState(null); + const [dedicatedMemory, setDedicatedMemory] = useState(false); + const [dedicatedWorkspace, setDedicatedWorkspace] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -120,6 +122,8 @@ const ProfileEditorPage = () => { setComposioIntegrations(existing.composioIntegrations ?? null); setAllowedSkills(existing.allowedSkills ?? null); setAllowedMcpServers(existing.allowedMcpServers ?? null); + setDedicatedMemory(existing.dedicatedMemory ?? false); + setDedicatedWorkspace(existing.dedicatedWorkspace ?? false); }, [existing, isCreate, profiles.length]); const handleName = (value: string) => { @@ -159,6 +163,8 @@ const ProfileEditorPage = () => { composioIntegrations, allowedSkills, allowedMcpServers, + dedicatedMemory, + dedicatedWorkspace, builtIn: existing?.builtIn ?? false, }; try { @@ -348,6 +354,66 @@ const ProfileEditorPage = () => { /> + {/* Isolation */} + + + } + /> + + } + /> + {existing?.soulMdFile && ( + + {existing.soulMdFile} + + } + /> + )} + {existing?.workspaceDir && ( + + {existing.workspaceDir} + + } + /> + )} + {existing?.skillsDir && ( + + {existing.skillsDir} + + } + /> + )} + + {/* Capabilities */} ({ useT: () => ({ t: (key: string) => @@ -15,6 +20,7 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({ 'settings.cron.jobs.lastStatus': 'Last status', 'settings.cron.jobs.nextRun': 'Next run', 'settings.cron.jobs.pause': 'Pause', + 'settings.cron.jobs.profile': 'Profile', 'settings.cron.jobs.recentRuns': 'Recent runs', 'settings.cron.jobs.saving': 'Saving…', 'settings.cron.jobs.schedule': 'Schedule', @@ -108,6 +114,45 @@ describe('CoreJobList stable test hooks', () => { expect(onEditCoreJob).toHaveBeenCalledWith(job); }); + test('shows the attributed profile name when it resolves', () => { + render( + + ); + expect(screen.getByTestId(`cron-job-profile-${job.id}`)).toHaveTextContent('Writer'); + }); + + test('falls back to the raw profile id when the profile was deleted', () => { + render( + + ); + expect(screen.getByTestId(`cron-job-profile-${job.id}`)).toHaveTextContent('ghost'); + }); + + test('omits the profile row when the job has no attribution', () => { + renderList(); + expect(screen.queryByTestId(`cron-job-profile-${job.id}`)).not.toBeInTheDocument(); + }); + test('toggle button shows saving label when coreBusyKey targets the toggle', () => { render( void; /** Optional: when provided, an Edit button is rendered per row. */ onEditCoreJob?: (job: CoreCronJob) => void; + /** Agent profiles, used to resolve a job's attributed profile name. */ + profiles?: AgentProfile[]; } const CoreJobList = ({ @@ -25,9 +28,15 @@ const CoreJobList = ({ onLoadCoreRuns, onRemoveCoreJob, onEditCoreJob, + profiles = [], }: CoreJobListProps) => { const { t } = useT(); + // Resolve a job's attributed profile to a display name, falling back to the + // raw id when the profile has since been deleted. + const profileLabel = (profileId: string): string => + profiles.find(p => p.id === profileId)?.name || profileId; + const toggleButtonLabel = (job: CoreCronJob) => { if (coreBusyKey === `core-toggle:${job.id}`) { return t('settings.cron.jobs.saving'); @@ -103,6 +112,14 @@ const CoreJobList = ({ {new Date(job.next_run).toLocaleString()} + {job.profile_id && ( +
+ {t('settings.cron.jobs.profile')}{' '} + + {profileLabel(job.profile_id)} + +
+ )} {job.last_status && (
{t('settings.cron.jobs.lastStatus')}{' '} diff --git a/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx index 3879486a96..2c184bf617 100644 --- a/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx +++ b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx @@ -1,9 +1,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentProfile } from '../../../../types/agentProfile'; import type { CoreCronJob } from '../../../../utils/tauriCommands'; import CronJobFormModal, { type CronJobFormModalProps } from './CronJobFormModal'; +const sampleProfiles: AgentProfile[] = [ + { id: 'writer', name: 'Writer', description: '', agentId: 'orchestrator', builtIn: false }, + { + id: 'researcher', + name: 'Researcher', + description: '', + agentId: 'orchestrator', + builtIn: false, + }, +]; + // ── Mock i18n ────────────────────────────────────────────────────────── vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ @@ -34,6 +46,9 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({ 'settings.cron.jobs.formSessionTarget': 'Session target', 'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)', 'settings.cron.jobs.formSessionMain': 'Main session', + 'settings.cron.jobs.formProfile': 'Agent profile', + 'settings.cron.jobs.formProfileNone': 'No profile', + 'settings.cron.jobs.formProfileHint': 'Run this job as the selected profile.', 'settings.cron.jobs.formDelivery': 'Delivery mode', 'settings.cron.jobs.formDeliveryNone': 'None (output only)', 'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)', @@ -435,4 +450,82 @@ describe('', () => { fireEvent.click(screen.getByTestId('cron-form-job-type-agent')); expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument(); }); + + // ── Agent profile attribution picker ───────────────────────────────── + it('renders a profile picker with a "no profile" default plus each profile for agent jobs', () => { + render(); + const picker = screen.getByTestId('cron-form-profile') as HTMLSelectElement; + const optionValues = Array.from(picker.options).map(o => o.value); + expect(optionValues).toEqual(['', 'writer', 'researcher']); + // Defaults to "no profile". + expect(picker.value).toBe(''); + }); + + it('associates the profile label with the select for screen readers', () => { + render(); + // getByLabelText only resolves when the
)} + {/* Agent profile attribution (agent only) */} + {jobType === 'agent' && ( +
+ + +

+ {t('settings.cron.jobs.formProfileHint')} +

+
+ )} + {/* Delivery mode (agent only) */} {jobType === 'agent' && (
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 26a087e20d..8686367cda 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5033,6 +5033,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'معزول (موصى به)', 'settings.cron.jobs.formSessionMain': 'الجلسة الرئيسية', 'settings.cron.jobs.formSessionTarget': 'هدف الجلسة', + 'settings.cron.jobs.formProfile': 'ملف الوكيل', + 'settings.cron.jobs.formProfileNone': 'بدون ملف', + 'settings.cron.jobs.formProfileHint': + 'شغّل هذه المهمة بالملف المحدد، مستخدمًا روحه وذاكرته ومساحة عمله.', + 'settings.cron.jobs.profile': 'الملف', 'settings.cron.jobs.lastStatus': 'آخر حالة', 'settings.cron.jobs.loading': 'جارٍ تحميل مهام cron...', 'settings.cron.jobs.loadingRuns': 'جارٍ تحميل عمليات التشغيل', @@ -7124,6 +7129,17 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'سير العمل الذي يمكن لهذا الملف سرده وتشغيله.', 'settings.profiles.editor.mcpServers': 'خوادم MCP', 'settings.profiles.editor.mcpServersHint': 'خوادم MCP التي يمكن لهذا الملف الوصول إليها.', + 'settings.profiles.editor.dedicatedMemory': 'ذاكرة مخصصة', + 'settings.profiles.editor.dedicatedMemoryHint': + 'امنح هذا الملف ذاكرته الخاصة بدلاً من مشاركة الذاكرة الافتراضية.', + 'settings.profiles.editor.dedicatedWorkspace': 'مساحة عمل مخصصة', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'امنح هذا الملف مجلد عمل خاص به لعمليات الملفات والأدوات.', + 'settings.profiles.editor.soulMdFile': 'ملف SOUL.md', + 'settings.profiles.editor.workspaceDir': 'مجلد مساحة العمل', + 'settings.profiles.editor.skillsDir': 'دليل المهارات', + 'settings.profiles.editor.skillsDirHint': + 'ملفات SKILL.md الموضوعة هنا خاصة بهذا الملف الشخصي وحده.', 'settings.profiles.editor.all': 'الكل', 'settings.profiles.editor.selected': 'محدّد', 'settings.profiles.editor.addPlaceholder': 'اكتب معرّفًا ثم اضغط Enter', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 675bcdd5b2..0d99294d7b 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5153,6 +5153,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'বিচ্ছিন্ন (প্রস্তাবিত)', 'settings.cron.jobs.formSessionMain': 'প্রধান সেশন', 'settings.cron.jobs.formSessionTarget': 'সেশন টার্গেট', + 'settings.cron.jobs.formProfile': 'এজেন্ট প্রোফাইল', + 'settings.cron.jobs.formProfileNone': 'কোনো প্রোফাইল নয়', + 'settings.cron.jobs.formProfileHint': + 'নির্বাচিত প্রোফাইল হিসেবে এই কাজটি চালান, তার পরিচয়, মেমরি ও ওয়ার্কস্পেস ব্যবহার করে।', + 'settings.cron.jobs.profile': 'প্রোফাইল', 'settings.cron.jobs.lastStatus': 'শেষ স্ট্যাটাস', 'settings.cron.jobs.loading': 'ক্রন জব লোড হচ্ছে...', 'settings.cron.jobs.loadingRuns': 'রান লোড হচ্ছে', @@ -7290,6 +7295,17 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'এই প্রোফাইল যেসব ওয়ার্কফ্লো তালিকাভুক্ত ও চালাতে পারে।', 'settings.profiles.editor.mcpServers': 'MCP সার্ভার', 'settings.profiles.editor.mcpServersHint': 'এই প্রোফাইল যেসব MCP সার্ভারে পৌঁছাতে পারে।', + 'settings.profiles.editor.dedicatedMemory': 'নিবেদিত মেমরি', + 'settings.profiles.editor.dedicatedMemoryHint': + 'ডিফল্ট মেমরি ভাগ করার পরিবর্তে এই প্রোফাইলকে নিজস্ব মেমরি দিন।', + 'settings.profiles.editor.dedicatedWorkspace': 'নিবেদিত ওয়ার্কস্পেস', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'ফাইল ও টুল কাজের জন্য এই প্রোফাইলকে নিজস্ব ওয়ার্কিং ডিরেক্টরি দিন।', + 'settings.profiles.editor.soulMdFile': 'SOUL.md ফাইল', + 'settings.profiles.editor.workspaceDir': 'ওয়ার্কস্পেস ডিরেক্টরি', + 'settings.profiles.editor.skillsDir': 'স্কিল ডিরেক্টরি', + 'settings.profiles.editor.skillsDirHint': + 'এখানে রাখা SKILL.md ফাইলগুলি শুধু এই প্রোফাইলের জন্য ব্যক্তিগত।', 'settings.profiles.editor.all': 'সব', 'settings.profiles.editor.selected': 'নির্বাচিত', 'settings.profiles.editor.addPlaceholder': 'একটি আইডি লিখে এন্টার চাপুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index d7784e8a18..1e5cde57e5 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5300,6 +5300,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isoliert (empfohlen)', 'settings.cron.jobs.formSessionMain': 'Hauptsitzung', 'settings.cron.jobs.formSessionTarget': 'Sitzungsziel', + 'settings.cron.jobs.formProfile': 'Agentenprofil', + 'settings.cron.jobs.formProfileNone': 'Kein Profil', + 'settings.cron.jobs.formProfileHint': + 'Diesen Auftrag als ausgewähltes Profil ausführen, mit dessen Seele, Gedächtnis und Arbeitsbereich.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Letzter Stand', 'settings.cron.jobs.loading': 'Cron-Jobs werden geladen...', 'settings.cron.jobs.loadingRuns': 'Ladeläufe', @@ -7505,6 +7510,17 @@ const messages: TranslationMap = { 'Workflows, die dieses Profil auflisten und ausführen kann.', 'settings.profiles.editor.mcpServers': 'MCP-Server', 'settings.profiles.editor.mcpServersHint': 'MCP-Server, die dieses Profil erreichen kann.', + 'settings.profiles.editor.dedicatedMemory': 'Dediziertes Gedächtnis', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Gib diesem Profil ein eigenes Gedächtnis, statt das Standardgedächtnis zu teilen.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedizierter Arbeitsbereich', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Gib diesem Profil ein eigenes Arbeitsverzeichnis für Datei- und Tool-Operationen.', + 'settings.profiles.editor.soulMdFile': 'SOUL.md-Datei', + 'settings.profiles.editor.workspaceDir': 'Arbeitsverzeichnis', + 'settings.profiles.editor.skillsDir': 'Skill-Verzeichnis', + 'settings.profiles.editor.skillsDirHint': + 'Hier abgelegte SKILL.md-Dateien sind privat für dieses Profil.', 'settings.profiles.editor.all': 'Alle', 'settings.profiles.editor.selected': 'Ausgewählt', 'settings.profiles.editor.addPlaceholder': 'Kennung eingeben und Enter drücken', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index be773c57ab..e6c7854da4 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5871,9 +5871,14 @@ const en: TranslationMap = { 'settings.cron.jobs.formScheduleRequired': 'Schedule is required', 'settings.cron.jobs.formScheduleType': 'Schedule type', 'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)', + 'settings.cron.jobs.formProfile': 'Agent profile', + 'settings.cron.jobs.formProfileNone': 'No profile', + 'settings.cron.jobs.formProfileHint': + 'Run this job as the selected profile, using its soul, memory, and workspace.', 'settings.cron.jobs.formSessionMain': 'Main session', 'settings.cron.jobs.formSessionTarget': 'Session target', 'settings.cron.jobs.lastStatus': 'Last status', + 'settings.cron.jobs.profile': 'Profile', 'settings.cron.jobs.loading': 'Loading cron jobs...', 'settings.cron.jobs.loadingRuns': 'Loading runs…', 'settings.cron.jobs.nextRun': 'Next run', @@ -7442,6 +7447,17 @@ const en: TranslationMap = { 'settings.profiles.editor.skillsHint': 'Workflows this profile can list and run.', 'settings.profiles.editor.mcpServers': 'MCP servers', 'settings.profiles.editor.mcpServersHint': 'MCP servers this profile can reach.', + 'settings.profiles.editor.dedicatedMemory': 'Dedicated memory', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Give this profile its own memory instead of sharing the default one.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedicated workspace', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Give this profile its own working directory for file and tool operations.', + 'settings.profiles.editor.soulMdFile': 'SOUL.md file', + 'settings.profiles.editor.workspaceDir': 'Workspace directory', + 'settings.profiles.editor.skillsDir': 'Skills directory', + 'settings.profiles.editor.skillsDirHint': + 'SKILL.md files placed here are private to this profile.', 'settings.profiles.editor.all': 'All', 'settings.profiles.editor.selected': 'Selected', 'settings.profiles.editor.addPlaceholder': 'Type an id, press Enter', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index cb1a392a52..a25ff0621b 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5246,6 +5246,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Aislada (recomendado)', 'settings.cron.jobs.formSessionMain': 'Sesión principal', 'settings.cron.jobs.formSessionTarget': 'Sesión de destino', + 'settings.cron.jobs.formProfile': 'Perfil del agente', + 'settings.cron.jobs.formProfileNone': 'Sin perfil', + 'settings.cron.jobs.formProfileHint': + 'Ejecuta esta tarea como el perfil seleccionado, usando su alma, memoria y espacio de trabajo.', + 'settings.cron.jobs.profile': 'Perfil', 'settings.cron.jobs.lastStatus': 'Último estado', 'settings.cron.jobs.loading': 'Cargando tareas cron...', 'settings.cron.jobs.loadingRuns': 'Cargando ejecuciones', @@ -7441,6 +7446,17 @@ const messages: TranslationMap = { 'Flujos de trabajo que este perfil puede listar y ejecutar.', 'settings.profiles.editor.mcpServers': 'Servidores MCP', 'settings.profiles.editor.mcpServersHint': 'Servidores MCP a los que puede acceder este perfil.', + 'settings.profiles.editor.dedicatedMemory': 'Memoria dedicada', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Dale a este perfil su propia memoria en lugar de compartir la memoria predeterminada.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espacio de trabajo dedicado', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Dale a este perfil su propio directorio de trabajo para operaciones de archivos y herramientas.', + 'settings.profiles.editor.soulMdFile': 'Archivo SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Directorio de trabajo', + 'settings.profiles.editor.skillsDir': 'Directorio de habilidades', + 'settings.profiles.editor.skillsDirHint': + 'Las habilidades basadas en SKILL.md colocadas aquí son privadas de este perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Seleccionados', 'settings.profiles.editor.addPlaceholder': 'Escribe un identificador y pulsa Intro', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 54f7ea04cc..849b063a85 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5276,6 +5276,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolée (recommandé)', 'settings.cron.jobs.formSessionMain': 'Session principale', 'settings.cron.jobs.formSessionTarget': 'Session cible', + 'settings.cron.jobs.formProfile': "Profil de l'agent", + 'settings.cron.jobs.formProfileNone': 'Aucun profil', + 'settings.cron.jobs.formProfileHint': + 'Exécuter cette tâche avec le profil sélectionné, en utilisant son âme, sa mémoire et son espace de travail.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Dernier statut', 'settings.cron.jobs.loading': 'Chargement des tâches cron…', 'settings.cron.jobs.loadingRuns': 'Chargement des exécutions', @@ -7475,6 +7480,17 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'Flux de travail que ce profil peut lister et exécuter.', 'settings.profiles.editor.mcpServers': 'Serveurs MCP', 'settings.profiles.editor.mcpServersHint': 'Serveurs MCP que ce profil peut atteindre.', + 'settings.profiles.editor.dedicatedMemory': 'Mémoire dédiée', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Donner à ce profil sa propre mémoire au lieu de partager la mémoire par défaut.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espace de travail dédié', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Donner à ce profil son propre répertoire de travail pour les opérations de fichiers et d’outils.', + 'settings.profiles.editor.soulMdFile': 'Fichier SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Répertoire de travail', + 'settings.profiles.editor.skillsDir': 'Répertoire des compétences', + 'settings.profiles.editor.skillsDirHint': + 'Les fichiers SKILL.md placés ici sont privés à ce profil.', 'settings.profiles.editor.all': 'Tous', 'settings.profiles.editor.selected': 'Sélectionnés', 'settings.profiles.editor.addPlaceholder': 'Saisissez un identifiant, puis Entrée', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index efb865e42b..55e18499a6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5152,6 +5152,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'पृथक (अनुशंसित)', 'settings.cron.jobs.formSessionMain': 'मुख्य सत्र', 'settings.cron.jobs.formSessionTarget': 'सत्र लक्ष्य', + 'settings.cron.jobs.formProfile': 'एजेंट प्रोफ़ाइल', + 'settings.cron.jobs.formProfileNone': 'कोई प्रोफ़ाइल नहीं', + 'settings.cron.jobs.formProfileHint': + 'इस कार्य को चयनित प्रोफ़ाइल के रूप में चलाएँ, उसकी पहचान, स्मृति और कार्यस्थान का उपयोग करते हुए।', + 'settings.cron.jobs.profile': 'प्रोफ़ाइल', 'settings.cron.jobs.lastStatus': 'आखिरी स्टेटस', 'settings.cron.jobs.loading': 'Cron jobs लोड हो रही हैं...', 'settings.cron.jobs.loadingRuns': 'रन लोड हो रहे हैं', @@ -7284,6 +7289,17 @@ const messages: TranslationMap = { 'इस प्रोफ़ाइल द्वारा सूचीबद्ध और चलाए जा सकने वाले वर्कफ़्लो।', 'settings.profiles.editor.mcpServers': 'MCP सर्वर', 'settings.profiles.editor.mcpServersHint': 'इस प्रोफ़ाइल द्वारा पहुँचने योग्य MCP सर्वर।', + 'settings.profiles.editor.dedicatedMemory': 'समर्पित मेमोरी', + 'settings.profiles.editor.dedicatedMemoryHint': + 'डिफ़ॉल्ट मेमोरी साझा करने के बजाय इस प्रोफ़ाइल को अपनी खुद की मेमोरी दें।', + 'settings.profiles.editor.dedicatedWorkspace': 'समर्पित वर्कस्पेस', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'फ़ाइल और टूल कार्यों के लिए इस प्रोफ़ाइल को अपनी खुद की वर्किंग डायरेक्टरी दें।', + 'settings.profiles.editor.soulMdFile': 'SOUL.md फ़ाइल', + 'settings.profiles.editor.workspaceDir': 'वर्कस्पेस डायरेक्टरी', + 'settings.profiles.editor.skillsDir': 'स्किल निर्देशिका', + 'settings.profiles.editor.skillsDirHint': + 'यहाँ रखी गई SKILL.md फ़ाइलें केवल इस प्रोफ़ाइल के लिए निजी हैं।', 'settings.profiles.editor.all': 'सभी', 'settings.profiles.editor.selected': 'चयनित', 'settings.profiles.editor.addPlaceholder': 'आईडी टाइप करें, एंटर दबाएँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 309985363d..b4378cdbad 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5175,6 +5175,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Terisolasi (disarankan)', 'settings.cron.jobs.formSessionMain': 'Sesi utama', 'settings.cron.jobs.formSessionTarget': 'Target sesi', + 'settings.cron.jobs.formProfile': 'Profil agen', + 'settings.cron.jobs.formProfileNone': 'Tanpa profil', + 'settings.cron.jobs.formProfileHint': + 'Jalankan tugas ini sebagai profil yang dipilih, menggunakan jiwa, memori, dan ruang kerjanya.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Status terakhir', 'settings.cron.jobs.loading': 'Memuat cron job...', 'settings.cron.jobs.loadingRuns': 'Memuat run', @@ -7322,6 +7327,17 @@ const messages: TranslationMap = { 'Alur kerja yang dapat didaftar dan dijalankan profil ini.', 'settings.profiles.editor.mcpServers': 'Server MCP', 'settings.profiles.editor.mcpServersHint': 'Server MCP yang dapat dijangkau profil ini.', + 'settings.profiles.editor.dedicatedMemory': 'Memori khusus', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Berikan profil ini memorinya sendiri alih-alih berbagi memori bawaan.', + 'settings.profiles.editor.dedicatedWorkspace': 'Ruang kerja khusus', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Berikan profil ini direktori kerjanya sendiri untuk operasi file dan alat.', + 'settings.profiles.editor.soulMdFile': 'Berkas SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Direktori ruang kerja', + 'settings.profiles.editor.skillsDir': 'Direktori keterampilan', + 'settings.profiles.editor.skillsDirHint': + 'File SKILL.md yang ditempatkan di sini bersifat pribadi untuk profil ini.', 'settings.profiles.editor.all': 'Semua', 'settings.profiles.editor.selected': 'Terpilih', 'settings.profiles.editor.addPlaceholder': 'Ketik id, tekan Enter', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 84a4eb0db5..cb939c52e3 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5239,6 +5239,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolata (consigliata)', 'settings.cron.jobs.formSessionMain': 'Sessione principale', 'settings.cron.jobs.formSessionTarget': 'Sessione di destinazione', + 'settings.cron.jobs.formProfile': "Profilo dell'agente", + 'settings.cron.jobs.formProfileNone': 'Nessun profilo', + 'settings.cron.jobs.formProfileHint': + 'Esegui questo lavoro come il profilo selezionato, usando la sua anima, memoria e spazio di lavoro.', + 'settings.cron.jobs.profile': 'Profilo', 'settings.cron.jobs.lastStatus': 'Ultimo stato', 'settings.cron.jobs.loading': 'Caricamento cron job...', 'settings.cron.jobs.loadingRuns': 'Caricamento esecuzioni', @@ -7426,6 +7431,17 @@ const messages: TranslationMap = { 'Flussi di lavoro che questo profilo può elencare ed eseguire.', 'settings.profiles.editor.mcpServers': 'Server MCP', 'settings.profiles.editor.mcpServersHint': 'Server MCP raggiungibili da questo profilo.', + 'settings.profiles.editor.dedicatedMemory': 'Memoria dedicata', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Assegna a questo profilo una memoria propria invece di condividere quella predefinita.', + 'settings.profiles.editor.dedicatedWorkspace': 'Area di lavoro dedicata', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Assegna a questo profilo una propria directory di lavoro per le operazioni su file e strumenti.', + 'settings.profiles.editor.soulMdFile': 'File SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Directory di lavoro', + 'settings.profiles.editor.skillsDir': 'Cartella delle competenze', + 'settings.profiles.editor.skillsDirHint': + 'I file SKILL.md inseriti qui sono privati per questo profilo.', 'settings.profiles.editor.all': 'Tutti', 'settings.profiles.editor.selected': 'Selezionati', 'settings.profiles.editor.addPlaceholder': 'Digita un identificativo e premi Invio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 74036bb72a..923d09c802 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5094,6 +5094,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': '격리됨 (권장)', 'settings.cron.jobs.formSessionMain': '메인 세션', 'settings.cron.jobs.formSessionTarget': '세션 대상', + 'settings.cron.jobs.formProfile': '에이전트 프로필', + 'settings.cron.jobs.formProfileNone': '프로필 없음', + 'settings.cron.jobs.formProfileHint': + '선택한 프로필로 이 작업을 실행하며, 해당 프로필의 정체성, 메모리, 작업 공간을 사용합니다.', + 'settings.cron.jobs.profile': '프로필', 'settings.cron.jobs.lastStatus': '마지막 상태', 'settings.cron.jobs.loading': 'cron 작업 불러오는 중...', 'settings.cron.jobs.loadingRuns': '실행 기록 불러오는 중', @@ -7202,6 +7207,16 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': '이 프로필이 나열하고 실행할 수 있는 워크플로.', 'settings.profiles.editor.mcpServers': 'MCP 서버', 'settings.profiles.editor.mcpServersHint': '이 프로필이 접근할 수 있는 MCP 서버.', + 'settings.profiles.editor.dedicatedMemory': '전용 메모리', + 'settings.profiles.editor.dedicatedMemoryHint': + '기본 메모리를 공유하는 대신 이 프로필에 전용 메모리를 부여합니다.', + 'settings.profiles.editor.dedicatedWorkspace': '전용 작업 공간', + 'settings.profiles.editor.dedicatedWorkspaceHint': + '파일 및 도구 작업을 위해 이 프로필에 전용 작업 디렉터리를 부여합니다.', + 'settings.profiles.editor.soulMdFile': 'SOUL.md 파일', + 'settings.profiles.editor.workspaceDir': '작업 공간 디렉터리', + 'settings.profiles.editor.skillsDir': '기술 디렉터리', + 'settings.profiles.editor.skillsDirHint': '여기에 넣은 SKILL.md 파일은 이 프로필에만 적용됩니다.', 'settings.profiles.editor.all': '전체', 'settings.profiles.editor.selected': '선택됨', 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 28350bac1c..cb52d1c725 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5235,6 +5235,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Izolowana (zalecane)', 'settings.cron.jobs.formSessionMain': 'Sesja główna', 'settings.cron.jobs.formSessionTarget': 'Docelowa sesja', + 'settings.cron.jobs.formProfile': 'Profil agenta', + 'settings.cron.jobs.formProfileNone': 'Brak profilu', + 'settings.cron.jobs.formProfileHint': + 'Uruchom to zadanie jako wybrany profil, korzystając z jego duszy, pamięci i przestrzeni roboczej.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Ostatni status', 'settings.cron.jobs.loading': 'Wczytywanie zadań cron...', 'settings.cron.jobs.loadingRuns': 'Wczytywanie uruchomień', @@ -7400,6 +7405,17 @@ const messages: TranslationMap = { 'Przepływy pracy, które ten profil może wyświetlać i uruchamiać.', 'settings.profiles.editor.mcpServers': 'Serwery MCP', 'settings.profiles.editor.mcpServersHint': 'Serwery MCP, do których ma dostęp ten profil.', + 'settings.profiles.editor.dedicatedMemory': 'Dedykowana pamięć', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Nadaj temu profilowi własną pamięć zamiast korzystać z pamięci współdzielonej.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedykowany obszar roboczy', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Nadaj temu profilowi własny katalog roboczy do operacji na plikach i narzędziach.', + 'settings.profiles.editor.soulMdFile': 'Plik SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Katalog roboczy', + 'settings.profiles.editor.skillsDir': 'Katalog umiejętności', + 'settings.profiles.editor.skillsDirHint': + 'Pliki SKILL.md umieszczone tutaj są prywatne dla tego profilu.', 'settings.profiles.editor.all': 'Wszystkie', 'settings.profiles.editor.selected': 'Wybrane', 'settings.profiles.editor.addPlaceholder': 'Wpisz identyfikator i naciśnij Enter', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index c735c9d1b2..8232dc80fa 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5228,6 +5228,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolada (recomendada)', 'settings.cron.jobs.formSessionMain': 'Sessão principal', 'settings.cron.jobs.formSessionTarget': 'Sessão de destino', + 'settings.cron.jobs.formProfile': 'Perfil do agente', + 'settings.cron.jobs.formProfileNone': 'Sem perfil', + 'settings.cron.jobs.formProfileHint': + 'Execute esta tarefa como o perfil selecionado, usando sua alma, memória e espaço de trabalho.', + 'settings.cron.jobs.profile': 'Perfil', 'settings.cron.jobs.lastStatus': 'Último status', 'settings.cron.jobs.loading': 'Carregando tarefas cron...', 'settings.cron.jobs.loadingRuns': 'Carregando execuções', @@ -7408,6 +7413,17 @@ const messages: TranslationMap = { 'Fluxos de trabalho que este perfil pode listar e executar.', 'settings.profiles.editor.mcpServers': 'Servidores MCP', 'settings.profiles.editor.mcpServersHint': 'Servidores MCP que este perfil pode acessar.', + 'settings.profiles.editor.dedicatedMemory': 'Memória dedicada', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Dê a este perfil sua própria memória em vez de compartilhar a memória padrão.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espaço de trabalho dedicado', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Dê a este perfil seu próprio diretório de trabalho para operações de arquivos e ferramentas.', + 'settings.profiles.editor.soulMdFile': 'Arquivo SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Diretório de trabalho', + 'settings.profiles.editor.skillsDir': 'Diretório de habilidades', + 'settings.profiles.editor.skillsDirHint': + 'Os arquivos SKILL.md colocados aqui são privados deste perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Selecionados', 'settings.profiles.editor.addPlaceholder': 'Digite um identificador e pressione Enter', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b2c97525d7..3797af95f6 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5203,6 +5203,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Изолированная (рекомендуется)', 'settings.cron.jobs.formSessionMain': 'Основная сессия', 'settings.cron.jobs.formSessionTarget': 'Целевая сессия', + 'settings.cron.jobs.formProfile': 'Профиль агента', + 'settings.cron.jobs.formProfileNone': 'Без профиля', + 'settings.cron.jobs.formProfileHint': + 'Запускать эту задачу от выбранного профиля, используя его душу, память и рабочее пространство.', + 'settings.cron.jobs.profile': 'Профиль', 'settings.cron.jobs.lastStatus': 'Последний статус', 'settings.cron.jobs.loading': 'Загрузка заданий...', 'settings.cron.jobs.loadingRuns': 'Загрузка запусков', @@ -7372,6 +7377,17 @@ const messages: TranslationMap = { 'settings.profiles.editor.mcpServers': 'Серверы MCP', 'settings.profiles.editor.mcpServersHint': 'Серверы MCP, к которым может обращаться этот профиль.', + 'settings.profiles.editor.dedicatedMemory': 'Выделенная память', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Выделить этому профилю собственную память вместо общей памяти по умолчанию.', + 'settings.profiles.editor.dedicatedWorkspace': 'Выделенное рабочее пространство', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Выделить этому профилю собственный рабочий каталог для операций с файлами и инструментами.', + 'settings.profiles.editor.soulMdFile': 'Файл SOUL.md', + 'settings.profiles.editor.workspaceDir': 'Рабочий каталог', + 'settings.profiles.editor.skillsDir': 'Каталог навыков', + 'settings.profiles.editor.skillsDirHint': + 'Файлы SKILL.md, размещённые здесь, доступны только этому профилю.', 'settings.profiles.editor.all': 'Все', 'settings.profiles.editor.selected': 'Выбранные', 'settings.profiles.editor.addPlaceholder': 'Введите идентификатор и нажмите Enter', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 733edc1180..f19c3aa57c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4879,6 +4879,10 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': '隔离(推荐)', 'settings.cron.jobs.formSessionMain': '主会话', 'settings.cron.jobs.formSessionTarget': '会话目标', + 'settings.cron.jobs.formProfile': '智能体配置', + 'settings.cron.jobs.formProfileNone': '不使用配置', + 'settings.cron.jobs.formProfileHint': '以所选配置运行此任务,使用其灵魂、记忆和工作空间。', + 'settings.cron.jobs.profile': '配置', 'settings.cron.jobs.lastStatus': '上次状态', 'settings.cron.jobs.loading': '正在加载定时任务...', 'settings.cron.jobs.loadingRuns': '加载运行记录中', @@ -6891,6 +6895,15 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': '此配置可列出并运行的工作流。', 'settings.profiles.editor.mcpServers': 'MCP 服务器', 'settings.profiles.editor.mcpServersHint': '此配置可访问的 MCP 服务器。', + 'settings.profiles.editor.dedicatedMemory': '专属记忆', + 'settings.profiles.editor.dedicatedMemoryHint': '为此配置分配专属记忆,而不是共享默认记忆。', + 'settings.profiles.editor.dedicatedWorkspace': '专属工作区', + 'settings.profiles.editor.dedicatedWorkspaceHint': + '为此配置分配专属工作目录,用于文件和工具操作。', + 'settings.profiles.editor.soulMdFile': 'SOUL.md 文件', + 'settings.profiles.editor.workspaceDir': '工作目录', + 'settings.profiles.editor.skillsDir': '技能目录', + 'settings.profiles.editor.skillsDirHint': '放在此处的 SKILL.md 文件仅对该配置私有。', 'settings.profiles.editor.all': '全部', 'settings.profiles.editor.selected': '指定', 'settings.profiles.editor.addPlaceholder': '输入标识后按回车', diff --git a/app/src/services/api/agentProfilesApi.test.ts b/app/src/services/api/agentProfilesApi.test.ts index ffaa6ab352..29e70ba77d 100644 --- a/app/src/services/api/agentProfilesApi.test.ts +++ b/app/src/services/api/agentProfilesApi.test.ts @@ -69,6 +69,37 @@ describe('agentProfilesApi', () => { }); }); + it('forwards dedicatedMemory/dedicatedWorkspace on upsert and round-trips the read-only resolved paths on list', async () => { + const profile = { + id: 'writer', + name: 'Writer', + description: 'Drafts copy.', + agentId: 'orchestrator', + builtIn: false, + dedicatedMemory: true, + dedicatedWorkspace: true, + }; + const enriched = { + ...profile, + soulMdFile: '/workspace/personalities/writer/SOUL.md', + workspaceDir: '/action/profiles/writer', + }; + const response = { profiles: [enriched], activeProfileId: 'writer' }; + + mockCallCoreRpc.mockResolvedValueOnce({ data: response }); + const { agentProfilesApi } = await import('./agentProfilesApi'); + await expect(agentProfilesApi.upsert(profile)).resolves.toEqual(response); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.profiles_upsert', + params: { profile }, + }); + + mockCallCoreRpc.mockResolvedValueOnce({ data: response }); + const listed = await agentProfilesApi.list(); + expect(listed.profiles[0].soulMdFile).toBe('/workspace/personalities/writer/SOUL.md'); + expect(listed.profiles[0].workspaceDir).toBe('/action/profiles/writer'); + }); + it('rejects malformed envelopes with undefined data', async () => { mockCallCoreRpc.mockResolvedValueOnce({ data: undefined }); diff --git a/app/src/types/agentProfile.ts b/app/src/types/agentProfile.ts index 1ced4110ca..19679044cf 100644 --- a/app/src/types/agentProfile.ts +++ b/app/src/types/agentProfile.ts @@ -25,6 +25,26 @@ export interface AgentProfile { memoryDirSuffix?: string | null; isMaster?: boolean | null; sortOrder?: number | null; + /** Give this profile its own memory subtree instead of the shared one. Default false. */ + dedicatedMemory?: boolean; + /** Give this profile its own working directory under action_dir. Default false. */ + dedicatedWorkspace?: boolean; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of `personalities//SOUL.md` when it exists. + */ + soulMdFile?: string; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of the dedicated workspace directory when `dedicatedWorkspace` is set. + */ + workspaceDir?: string; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of the profile's private `skills/` directory when it exists on disk. + * SKILL.md workflows placed there are scoped to this profile only. + */ + skillsDir?: string; } export interface AgentProfilesResponse { diff --git a/app/src/utils/tauriCommands/cron.ts b/app/src/utils/tauriCommands/cron.ts index 6379c1093d..4ae27a1939 100644 --- a/app/src/utils/tauriCommands/cron.ts +++ b/app/src/utils/tauriCommands/cron.ts @@ -32,6 +32,8 @@ export interface CoreCronJob { job_type: 'shell' | 'agent' | string; session_target: 'isolated' | 'main' | string; model?: string | null; + /** Agent profile this job runs as, when attributed (snake_case on the wire). */ + profile_id?: string | null; enabled: boolean; delivery: { mode: string; channel?: string | null; to?: string | null; best_effort: boolean }; delete_after_run: boolean; @@ -61,6 +63,8 @@ export interface CronAddParams { session_target?: 'isolated' | 'main'; model?: string; agent_id?: string; + /** Agent profile to attribute this job to (snake_case on the wire). Omit for none. */ + profile_id?: string; delivery?: { mode: string; channel?: string | null; to?: string | null; best_effort?: boolean }; delete_after_run?: boolean; } diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 4c027dd302..819affe971 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -614,15 +614,31 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 14.1.6 | Bounded live core-log buffer | RU | `src/core/logging.rs` | ✅ | Preserves ordering; bounds both line count and individual line length | | 14.1.7 | Interactive terminal session (raw mode, streaming) | MS | manual PTY smoke | 🚫 | Requires a real PTY to verify key input and terminal restoration | +## 15. Agent Profile Homes (multi-agent profiles) + +### 15.1 Profile Homes & Isolation + +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | -------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------- | +| 15.1.1 | Profile home materialization + file-backed SOUL.md | RU | `src/openhuman/profiles/home.rs`, `src/openhuman/profiles/paths.rs` | ✅ | Idempotent atomic seeds, id validation matrix, soul resolution order incl. legacy-id skip | +| 15.1.2 | Dedicated memory suffix + dedicated workspace descriptor | RU | `src/openhuman/profiles/paths.rs`, `src/openhuman/agent/harness/session/builder/factory.rs` | ✅ | `effective_memory_suffix` matrix; real `derive_profile_workspace_descriptor` seam, None path unchanged | +| 15.1.3 | Cross-profile write guard (file tools + shell) | RU | `src/openhuman/profiles/guard.rs`, `src/openhuman/security/policy/policy_tests.rs` | ✅ | Block sibling / allow own / disarmed-allows; traversal + symlink cases through real `validate_parent_path` | +| 15.1.4 | Profile-scoped agent experience (capture stamp + partition) | RU | `src/openhuman/agent_experience/` (capture/store/ops tests) | ✅ | Profiled query sees own + legacy, excludes siblings; profile-less sees all | +| 15.1.5 | Per-profile skills dir (discover/describe/read/run precedence) | RU | `src/openhuman/skills/registry.rs`, `src/openhuman/skills/ops_discover.rs` | ✅ | Owner-only visibility; profile-local wins collisions; global-only resolves everywhere | +| 15.1.6 | Cron profile attribution (schema, patch clearing, fallback) | RU/RI | `src/openhuman/cron/types.rs`, `src/openhuman/cron/store.rs`, `tests/json_rpc_e2e.rs` | ✅ | Double-option wire semantics (absent/null/value); deleted-profile fallback; e2e round-trip incl. null clearing | +| 15.1.7 | Profiles RPC lifecycle (dedicated memory, enriched paths) | RI | `tests/json_rpc_e2e.rs` (`json_rpc_profiles_dedicated_memory_lifecycle`) | ✅ | Upsert → list shows `dedicatedMemory` + enriched `soulMdFile`/`workspaceDir`/`skillsDir` | +| 15.1.8 | Profile editor isolation UI (toggles, path rows) | VU | `app/src/components/settings/panels/ProfileEditorPage.test.tsx` | ✅ | Default-off render, dispatch payload, hydration, path rows shown/hidden | +| 15.1.9 | Cron profile picker UI (assign, clear, deleted fallback) | VU | `app/src/components/settings/panels/cron/` tests, `app/src/services/api/agentProfilesApi.test.ts` | ✅ | Create with/without id, edit prefill, clear→null, deleted-profile preserved, list label fallback | + ## Summary | Status | Count | | ---------------- | ------------------------------------------------ | -| ✅ Covered | 70 | +| ✅ Covered | 79 | | 🟡 Partial | 27 | | ❌ Missing | 26 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **135 explicit + nested = 206 product features** | +| **Total leaves** | **143 explicit + nested = 214 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 74dae91590..7329d69869 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -1434,7 +1434,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ name: "Persona Pack", domain: "settings", category: CapabilityCategory::Settings, - description: "Personalize the assistant as one identity: set a display name and description, edit or reset the SOUL.md personality prompt, and reach mascot avatar and voice settings — all from a single Persona surface.", + description: "Personalize the assistant across one or more agent profiles: set a display name and description, edit or reset each profile's SOUL.md identity (kept in its own home under personalities// and re-read every message), give a profile its own dedicated memory subtree or its own working directory, drop private skills under personalities//skills/ that only that profile can discover, attribute a scheduled cron job to a profile so it runs with that profile's identity, memory, and permissions, and reach mascot avatar and voice settings. Multiple profiles can run with isolated identity, memory, skills, and workspace state.", how_to: "Settings > Persona", status: CapabilityStatus::Beta, privacy: GITHUB_MASCOT_MANIFEST, diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index 2957a36117..b2d2ddf9fd 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -60,8 +60,10 @@ pub struct ParentExecutionContext { /// to reason about what the parent can *actually* invoke — e.g. /// `agent_prepare_context` recommending next tool calls — must consult /// this, not `all_tool_specs` (which is the full registry, including - /// hidden direct-exec/spawn tools the parent never advertises). Empty - /// means "unknown" — callers should treat that as "no restriction". + /// hidden direct-exec/spawn tools the parent never advertises). The + /// sub-agent runner intersects child scopes with this set so profile and + /// channel allowlists cannot be widened through delegation. Empty means + /// "unknown" — callers should treat that as "no restriction". pub visible_tool_names: std::collections::HashSet, /// Model name the parent is currently using (after classification). diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 07291137af..ad51e2fe15 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -241,6 +241,264 @@ async fn build_session_agent_falls_back_to_global_default_when_no_definition() { ); } +// ── 1a: active profile id plumbed onto the built session ───────────────────── + +#[tokio::test] +async fn build_session_agent_carries_active_profile_id_when_profile_present() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.is_master = false; + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a profile should succeed"); + + assert_eq!( + agent.active_profile_id.as_deref(), + Some("alice"), + "an active profile must plumb its id onto the built session" + ); +} + +#[tokio::test] +async fn profile_allowed_tools_restrict_shared_session_builder() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.allowed_tools = Some(vec!["file_read".to_string()]); + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build profile-scoped session"); + + assert_eq!( + agent.visible_tool_names_for_test(), + &["file_read".to_string()].into_iter().collect(), + "every profile-aware caller must inherit the same tool restriction" + ); +} + +#[tokio::test] +async fn dedicated_memory_profile_scopes_tree_and_transcript_storage() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.dedicated_memory = true; + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build dedicated-memory session"); + + assert_eq!(agent.memory_subdir, "memory-alice"); + assert_eq!(agent.session_raw_subdir, "session_raw-alice"); +} + +#[tokio::test] +async fn build_session_agent_leaves_active_profile_id_none_without_profile() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + // The profile-less path (the legacy default) must stay byte-identical: + // no active profile id is stamped. + let agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner with no profile should succeed"); + + assert_eq!( + agent.active_profile_id, None, + "the profile-less session must not carry an active profile id" + ); +} + +// ── Finding #1 (Codex): dedicated memory subtree on the ordinary session path ─ + +/// Build a non-default profile with the given id + dedicated-memory flag. +fn custom_profile(id: &str, dedicated_memory: bool) -> crate::openhuman::profiles::AgentProfile { + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = id.to_string(); + profile.name = id.to_string(); + profile.built_in = false; + profile.is_master = false; + profile.memory_dir_suffix = None; + profile.dedicated_memory = dedicated_memory; + profile +} + +#[tokio::test] +async fn build_session_agent_routes_dedicated_memory_to_profile_subtree() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let profile = custom_profile("alice", true); + + let _agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a dedicated-memory profile should succeed"); + + // The session's capture/recall store (UnifiedMemory) is rooted at + // `/memory-alice`, not the shared `memory/` tree. + assert!( + config + .workspace_dir + .join("memory-alice") + .join("memory.db") + .exists(), + "a dedicatedMemory profile must route session memory to memory-" + ); +} + +#[tokio::test] +async fn build_session_agent_profile_less_uses_shared_memory_subtree() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Profile-less path stays byte-identical: session memory uses the shared + // `memory/` subtree, and no per-profile subtree is created. + let _agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner without a profile should succeed"); + + assert!( + config + .workspace_dir + .join("memory") + .join("memory.db") + .exists(), + "the profile-less session must use the shared memory subtree" + ); + assert!( + !config.workspace_dir.join("memory-alice").exists(), + "no per-profile memory subtree should exist for a profile-less session" + ); +} + +// ── Finding #2 (Codex): profile SOUL.md injected into the live session prompt ─ + +#[tokio::test] +async fn build_session_agent_injects_profile_soul_into_prompt() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + std::fs::write( + config.workspace_dir.join("SOUL.md"), + "I am the conflicting workspace-root identity.", + ) + .unwrap(); + // Seed the non-default profile's home SOUL.md (as ensure_profile_home would). + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "I am Alice, a meticulous archivist.").unwrap(); + + let profile = custom_profile("alice", false); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a profile should succeed"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + prompt.contains("I am Alice, a meticulous archivist."), + "the live profile session prompt must include the profile SOUL.md content" + ); + assert!( + !prompt.contains("I am the conflicting workspace-root identity."), + "profile SOUL.md must replace, not accompany, workspace-root SOUL.md" + ); +} + +#[tokio::test] +async fn build_session_agent_uses_profile_memory_instead_of_root_memory() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + std::fs::write( + config.workspace_dir.join("MEMORY.md"), + "shared root memory marker", + ) + .unwrap(); + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("MEMORY.md"), "alice private memory marker").unwrap(); + + let profile = custom_profile("alice", false); + let orchestrator = builtin_def("orchestrator"); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + Some(&orchestrator), + None, + None, + false, + Some(&profile), + ) + .expect("build profile session"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!(prompt.contains("alice private memory marker")); + assert!(!prompt.contains("shared root memory marker")); +} + // ───────────────────────────────────────────────────────────────────────────── // B38 (Gap 2) — a custom (non-shipped) `AgentRegistryEntry` must synthesize a // real `AgentDefinition` and run with its own `ToolScope::Named` filter, @@ -317,6 +575,68 @@ async fn from_config_for_agent_synthesizes_custom_registry_entry_with_named_scop ); } +#[tokio::test] +async fn build_session_agent_injects_default_profile_soul_into_prompt() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let home = config.workspace_dir.join("personalities").join("default"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write( + home.join("SOUL.md"), + "I am the user-edited Default profile identity.", + ) + .unwrap(); + + let profile = crate::openhuman::profiles::store::built_in_default_profile(); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build default-profile session"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + prompt.contains("I am the user-edited Default profile identity."), + "the live Default profile prompt must include personalities/default/SOUL.md" + ); +} + +#[tokio::test] +async fn build_session_agent_profile_less_prompt_has_no_personality_soul() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + // A personalities/alice/SOUL.md exists on disk, but a profile-less session + // must never pull it — the prompt stays byte-identical to today. + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "I am Alice, a meticulous archivist.").unwrap(); + + let agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner without a profile should succeed"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + !prompt.contains("I am Alice, a meticulous archivist."), + "a profile-less session must not inject any profile SOUL.md" + ); +} + #[tokio::test] async fn from_config_for_agent_still_errors_for_a_genuinely_unknown_id() { use crate::openhuman::agent::harness::session::types::Agent; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 89600c7a79..4cc90df74c 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -267,14 +267,39 @@ impl Agent { "[profiles] applying per-profile session gate" ); } + + // Section D — per-profile dedicated workspace. When the active profile + // opts into `dedicated_workspace` (and its id passes validation), derive + // a `WorkspaceDescriptor` rooted at `/profiles/` and + // thread it into the top-level chat turn so acting tools (shell/file/git) + // resolve their default cwd there. Because the dir is under `action_dir`, + // `SecurityPolicy` already permits it — no hardening change, and the + // agent's broad write root is left intact (cross-profile write guarding + // is a deliberate follow-up). `None` (the common case) preserves the + // shared-`action_dir` cwd behaviour byte-for-byte. + // + // NOTE (deliberate): this `ctx.workspace` descriptor propagates to + // subagents spawned from this session, so they too root under + // `/profiles/` rather than the bare `action_dir`. That + // propagation is *intended* profile isolation, not a leak — a profile's + // subagents should share its dedicated workspace. Do not "fix" it by + // clearing the descriptor for child sessions. + // + // The expression is extracted into [`derive_profile_workspace_descriptor`] + // so the unit tests exercise the *same* code path rather than a + // hand-copied mirror. + let profile_workspace_descriptor = + derive_profile_workspace_descriptor(&config.action_dir, profile); + let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, ); - let security = Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - &config.action_dir, - )); + // 1b — arm the cross-profile write guard for every active profile, + // independently of whether that profile uses (or successfully created) + // a dedicated workspace. A shared/default profile still must not reach + // another profile's `/profiles/` subtree from the broad + // action root. Profile-less sessions remain byte-identical. + let security = Arc::new(build_profile_security(config, profile)); // Phase 1 of #1401: see comment in channels/runtime/startup.rs. let audit = crate::openhuman::security::get_or_create_workspace_audit_logger( crate::openhuman::config::AuditConfig::default(), @@ -286,6 +311,30 @@ impl Agent { config, &config.memory.embedding_provider, ); + // Route this session's captures + recall into the active profile's memory + // subtree so `dedicatedMemory` isolation takes effect on the ordinary + // session path (web chat, cron), not just delegation preambles. The + // profile-less / default / shared cases resolve to `"memory"` + // (byte-identical): `effective_memory_suffix` returns `""` for them and + // `memory_subdir_for_suffix("")` == `"memory"`. A dedicated-memory profile + // yields `"memory-"`; a legacy numeric-suffix profile `"memory-"`. + let memory_subdir = profile + .map(|p| { + crate::openhuman::profiles::memory_subdir_for_suffix( + &crate::openhuman::profiles::effective_memory_suffix(p), + ) + }) + .unwrap_or_else(|| "memory".to_string()); + let memory_suffix = profile + .map(crate::openhuman::profiles::effective_memory_suffix) + .unwrap_or_default(); + let session_raw_subdir = + crate::openhuman::profiles::session_raw_subdir_for_suffix(&memory_suffix); + tracing::debug!( + memory_subdir = %memory_subdir, + has_profile = profile.is_some(), + "[profiles] session memory subtree selected" + ); let session_memory = memory_store::factories::create_session_memory_with_local_ai( &config.memory, local_embedding.as_deref(), @@ -293,9 +342,23 @@ impl Agent { &config.embedding_routes, Some(&config.storage.provider.config), &config.workspace_dir, + &memory_subdir, )?; let archivist_connection = session_memory.sqlite_connection; let memory: Arc = Arc::from(session_memory.memory); + // Dedicated profiles still recall unstamped experiences written by + // pre-profile versions from the shared memory DB. Retain the global + // shared handle explicitly on the session rather than making the hot + // turn path reload config or reach into process-global state. + let shared_experience_memory = if memory_subdir == "memory" { + None + } else { + Some( + crate::openhuman::memory::global::init(config.workspace_dir.clone()) + .map_err(anyhow::Error::msg)? + .memory_handle(), + ) + }; // Per-profile skill (workflow) + MCP-server allowlists. `None` = all. let profile_skill_allowlist: Option> = profile @@ -304,6 +367,21 @@ impl Agent { let profile_mcp_allowlist: Option> = profile.and_then(|p| p.allowed_mcp_servers.clone()); + // 2a — profile-local skills root (`/personalities//skills/`). + // Threaded into the harness workflow catalog AND the discovery/list tools + // so a turn running under this profile sees its private skills (implicitly + // allowed for their owner, winning same-name collisions). `None` for the + // profile-less session / legacy ids keeps discovery byte-identical. + let profile_skills_root: Option = profile.and_then(|p| { + crate::openhuman::profiles::profile_skills_root(&config.workspace_dir, &p.id) + }); + if let Some(root) = profile_skills_root.as_deref() { + tracing::debug!( + skills_root = %root.display(), + "[profiles] profile-local skills root active for this session" + ); + } + // Load the user's persisted tool preferences once. They drive two // things below: granting the App UI Control / App Automation mutation // opt-in (#3762) and filtering the tool set to the enabled snapshot. @@ -352,8 +430,13 @@ impl Agent { &tool_config.action_dir, &tool_config.agents, &tool_config, + profile, profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), + profile_skills_root.as_deref(), + profile_workspace_descriptor + .as_ref() + .map(|descriptor| descriptor.root.as_path()), ); // Filter tools by the user preference loaded above. @@ -611,17 +694,36 @@ impl Agent { prompt_builder = prompt_builder.with_reflection_context(chunks); } } - if let Some(suffix) = profile_prompt_suffix + // Compose the profile prompt section: the persona suffix, plus (1b) the + // cross-profile workspace notice when a dedicated workspace is active. + // The notice discloses the boundary the guard enforces, so it is added + // even when the profile carries no persona suffix. + let profile_suffix = profile_prompt_suffix .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - { + .filter(|s| !s.is_empty()); + let workspace_notice = profile_workspace_descriptor + .as_ref() + .and_then(|descriptor| { + profile.map(|p| { + crate::openhuman::profiles::cross_profile_workspace_notice( + &p.id, + &descriptor.root, + ) + }) + }); + if profile_suffix.is_some() || workspace_notice.is_some() { log::debug!( - "[agent:builder] profile prompt section injected suffix_chars={}", - suffix.chars().count() + "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={}", + profile_suffix.as_deref().map(|s| s.chars().count()).unwrap_or(0), + workspace_notice.is_some(), ); - prompt_builder = prompt_builder.add_section(Box::new( - crate::openhuman::profiles::AgentProfilePromptSection::new(suffix), - )); + let mut section = crate::openhuman::profiles::AgentProfilePromptSection::new( + profile_suffix.unwrap_or_default(), + ); + if let Some(notice) = workspace_notice { + section = section.with_workspace_notice(notice); + } + prompt_builder = prompt_builder.add_section(Box::new(section)); } // Build post-turn hooks when learning is enabled @@ -683,10 +785,14 @@ impl Agent { } if config.learning.tool_memory_capture_enabled { + // 1c — stamp captured experiences with the active profile id so + // retrieval can partition them. `None` for the profile-less + // session leaves records unstamped (shared/legacy). post_turn_hooks.push(Arc::new( - crate::openhuman::agent_experience::AgentExperienceCaptureHook::new( + crate::openhuman::agent_experience::AgentExperienceCaptureHook::with_profile( memory.clone(), true, + profile.map(|p| p.id.clone()), ), )); log::info!("[learning] agent_experience_capture hook registered"); @@ -908,6 +1014,35 @@ impl Agent { } } + // Profile tool selection is a restriction on the resolved agent + // definition, never a replacement for it. Apply it here at the shared + // session-builder seam so web chat, cron, tasks, and delegated profile + // runs all enforce the same callable surface. The web wrapper used to + // replace this set after construction, which both missed background + // runs and could broaden a named agent definition. + if let Some(allowed_tools) = profile + .and_then(|profile| profile.allowed_tools.as_ref()) + .filter(|tools| !tools.is_empty()) + { + let profile_visible: std::collections::HashSet<&str> = allowed_tools + .iter() + .map(|tool| tool.trim()) + .filter(|tool| !tool.is_empty()) + .collect(); + if visible.is_empty() { + visible = profile_visible.into_iter().map(str::to_string).collect(); + } else { + visible.retain(|tool| profile_visible.contains(tool.as_str())); + // Empty is the Agent's historical "all tools" sentinel. A + // disjoint profile/definition intersection must instead stay + // non-empty with an unregistered name so it advertises and + // permits zero tools rather than accidentally broadening. + if visible.is_empty() { + visible.insert("__profile_no_tools__".to_string()); + } + } + } + // Phase 4 (#566): add the MemoryAccessSection bias instruction only // when at least one retrieval tool is actually loaded AND survives // filtering. We require both because: @@ -995,13 +1130,13 @@ impl Agent { pformat_registry.len() ); - // Temperature override: when we have a target definition, use - // its declared temperature from the TOML (welcome is 0.7, - // orchestrator is 0.4, etc). Fall back to - // `config.default_temperature` for the legacy "no definition" - // path so existing callers keep getting their configured value. - let effective_temperature = target_def - .map(|def| def.temperature) + // Temperature override: an active profile is the user-selected runtime + // default; otherwise use the target definition's TOML value (welcome is + // 0.7, orchestrator is 0.4, etc). Fall back to config for the legacy + // no-definition path. + let effective_temperature = profile + .and_then(|profile| profile.temperature) + .or_else(|| target_def.map(|def| def.temperature)) .unwrap_or(config.default_temperature); // Thread PROFILE.md + MEMORY.md inclusion from the resolved @@ -1130,6 +1265,7 @@ impl Agent { .tools(tools) .visible_tool_names(visible) .memory(memory) + .shared_experience_memory(shared_experience_memory) .tool_dispatcher(tool_dispatcher) .memory_loader(Box::new( DefaultMemoryLoader::new(5, config.memory.min_relevance_score) @@ -1155,9 +1291,28 @@ impl Agent { .temperature(effective_temperature) .workspace_dir(config.workspace_dir.clone()) .action_dir(config.action_dir.clone()) - .workflows(crate::openhuman::skills::load_workflow_metadata( - &config.workspace_dir, - )) + .workspace_descriptor(profile_workspace_descriptor) + // 1a — carry the active profile id (any active profile, not just + // dedicated-workspace ones) so profile-scoped post-turn hooks can + // see which profile the turn ran under. `None` for the profile-less + // session keeps every consumer byte-identical. + .active_profile_id(profile.map(|p| p.id.clone())) + .personality_soul_md(profile.and_then(|profile| { + crate::openhuman::profiles::resolve_personality_soul(&config.workspace_dir, profile) + })) + .personality_memory_md(profile.and_then(|profile| { + crate::openhuman::profiles::resolve_personality_memory_md( + &config.workspace_dir, + profile, + ) + })) + .profile_memory_storage(memory_subdir, session_raw_subdir) + .workflows( + crate::openhuman::skills::load_workflow_metadata_for_profile( + &config.workspace_dir, + profile_skills_root.as_deref(), + ), + ) .auto_save(config.memory.auto_save) .post_turn_hooks(post_turn_hooks) .learning_enabled(config.learning.enabled) @@ -1474,3 +1629,159 @@ mod provider_role_tests { ); } } + +/// Section D — derive the top-level chat turn's per-profile workspace +/// descriptor. Shared by [`Agent::build_session_agent_inner`] and its unit tests +/// so the two can never drift. +/// +/// Returns a [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor) +/// rooted at `/profiles/` when `profile` opts into +/// `dedicated_workspace` and its id passes validation (via +/// [`dedicated_workspace_dir`](crate::openhuman::profiles::dedicated_workspace_dir)), +/// creating the dir as a side effect; `None` for the shared-workspace common case, +/// for legacy ids that fail validation, and when the directory can't be created +/// (all three fall back to the shared `action_dir` cwd rather than binding tools +/// to a nonexistent dir). The returned descriptor propagates to subagents — see +/// the deliberate-isolation note at the call site. +pub(crate) fn derive_profile_workspace_descriptor( + action_dir: &std::path::Path, + profile: Option<&crate::openhuman::profiles::AgentProfile>, +) -> Option { + let (profile_id, dir) = profile.and_then(|p| { + crate::openhuman::profiles::dedicated_workspace_dir(action_dir, p) + .map(|dir| (p.id.clone(), dir)) + })?; + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::warn!( + profile_id = %profile_id, + dir = %dir.display(), + error = %e, + "[profiles] failed to create dedicated workspace dir — \ + falling back to the shared action_dir cwd for this session" + ); + // Return None so callers fall back to the shared action_dir rather than + // binding every acting tool (shell/file/git) to a cwd that doesn't exist. + return None; + } + tracing::debug!( + profile_id = %profile_id, + dir = %dir.display(), + "[profiles] session bound to dedicated workspace as default cwd" + ); + Some( + tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) + .with_policy_id(crate::openhuman::profiles::workspace_policy_id(&profile_id)), + ) +} + +fn build_profile_security( + config: &crate::openhuman::config::Config, + profile: Option<&crate::openhuman::profiles::AgentProfile>, +) -> SecurityPolicy { + let base = + SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir, &config.action_dir); + match profile { + Some(profile) => base.with_active_profile(profile.id.clone(), config.action_dir.clone()), + None => base, + } +} + +/// Section D — per-profile dedicated-workspace descriptor seam. +/// +/// These tests exercise the **production** [`derive_profile_workspace_descriptor`] +/// directly (the same function the session builder calls), so they cannot drift +/// from the real seam. They pin that the descriptor root points at +/// `/profiles/` for an opted-in profile, and that shared/legacy +/// profiles produce no descriptor (so the shared `action_dir` cwd is preserved). +#[cfg(test)] +mod profile_workspace_descriptor_tests { + use super::{build_profile_security, derive_profile_workspace_descriptor}; + use crate::openhuman::profiles::store::built_in_default_profile; + + fn profile(id: &str, dedicated_workspace: bool) -> crate::openhuman::profiles::AgentProfile { + let mut p = built_in_default_profile(); + p.id = id.to_string(); + p.name = id.to_string(); + p.built_in = false; + p.is_master = false; + p.memory_dir_suffix = None; + p.dedicated_workspace = dedicated_workspace; + p + } + + #[test] + fn dedicated_workspace_profile_roots_descriptor_at_profile_dir() { + // Real temp action_dir: the production fn creates the profile dir as a + // side effect, so assert against the resolved path suffix. + let action = tempfile::tempdir().expect("action tempdir"); + let p = profile("alice", true); + let desc = derive_profile_workspace_descriptor(action.path(), Some(&p)) + .expect("dedicated_workspace profile yields a descriptor"); + let expected = action.path().join("profiles").join("alice"); + assert_eq!(desc.root.as_path(), expected.as_path()); + // The production path really created the dir. + assert!(desc.root.is_dir()); + } + + #[test] + fn shared_profile_yields_no_descriptor() { + let action = tempfile::tempdir().expect("action tempdir"); + let p = profile("bob", false); + assert!(derive_profile_workspace_descriptor(action.path(), Some(&p)).is_none()); + } + + #[test] + fn none_profile_yields_no_descriptor() { + let action = tempfile::tempdir().expect("action tempdir"); + assert!(derive_profile_workspace_descriptor(action.path(), None).is_none()); + } + + #[test] + fn legacy_invalid_id_yields_no_descriptor_even_when_opted_in() { + let action = tempfile::tempdir().expect("action tempdir"); + // An id that fails validation can't mint a workspace path → no descriptor, + // so the session falls back to the shared action_dir cwd. + let p = profile("Bad Id", true); + assert!(derive_profile_workspace_descriptor(action.path(), Some(&p)).is_none()); + } + + #[test] + fn create_dir_failure_yields_no_descriptor() { + // Point `action_dir` at a regular file so `profiles/…` can't be created. + // The function must fall back to `None` (shared action_dir cwd) rather + // than hand tools a descriptor rooted at a nonexistent dir. + let tmp = tempfile::tempdir().expect("tempdir"); + let action_file = tmp.path().join("not-a-dir"); + std::fs::write(&action_file, b"x").expect("write file"); + let p = profile("alice", true); + assert!( + derive_profile_workspace_descriptor(&action_file, Some(&p)).is_none(), + "a create_dir_all failure must fall back to None" + ); + } + + #[test] + fn shared_profile_still_arms_cross_profile_guard() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = crate::openhuman::config::Config::default(); + config.action_dir = temp.path().join("actions"); + config.workspace_dir = temp.path().join("state"); + let profile = profile("default", false); + + let security = build_profile_security(&config, Some(&profile)); + + let guard = security + .active_profile + .expect("every active profile must arm the guard"); + assert_eq!(guard.profile_id, "default"); + assert_eq!(guard.action_dir, config.action_dir); + } + + #[test] + fn profile_less_session_leaves_cross_profile_guard_disarmed() { + let config = crate::openhuman::config::Config::default(); + assert!(build_profile_security(&config, None) + .active_profile + .is_none()); + } +} diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 0ebd629e7a..237f0a40dc 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -23,6 +23,7 @@ impl AgentBuilder { tools: None, visible_tool_names: None, memory: None, + shared_experience_memory: None, prompt_builder: None, tool_dispatcher: None, memory_loader: None, @@ -33,6 +34,7 @@ impl AgentBuilder { temperature: None, workspace_dir: None, action_dir: None, + workspace_descriptor: None, workflows: None, auto_save: None, post_turn_hooks: Vec::new(), @@ -41,6 +43,11 @@ impl AgentBuilder { event_session_id: None, event_channel: None, agent_definition_name: None, + active_profile_id: None, + personality_soul_md: None, + personality_memory_md: None, + memory_subdir: None, + session_raw_subdir: None, session_parent_prefix: None, omit_profile: None, omit_memory_md: None, @@ -115,6 +122,13 @@ impl AgentBuilder { self } + /// Retains the shared store for experience recall when `memory` is a + /// dedicated profile subtree. + pub fn shared_experience_memory(mut self, memory: Option>) -> Self { + self.shared_experience_memory = memory; + self + } + /// Sets the system prompt builder for the agent. pub fn prompt_builder( mut self, @@ -188,6 +202,52 @@ impl AgentBuilder { self } + /// Sets the per-profile workspace descriptor (section D of agent-profile + /// homes). When set, the top-level chat turn threads it through so acting + /// tools resolve their default cwd to the profile's dedicated workspace. + pub fn workspace_descriptor( + mut self, + descriptor: Option, + ) -> Self { + self.workspace_descriptor = descriptor; + self + } + + /// Sets the active agent-profile id for this session (1a plumbing). + /// + /// `None` (default) is the profile-less session. When set, the id is + /// carried on the built [`Agent`] and threaded into the post-turn + /// [`TurnContext`](crate::openhuman::agent::hooks::TurnContext) so + /// profile-scoped hooks (agent-experience capture) can stamp records with + /// it. A `None` here keeps every downstream consumer on its legacy path. + pub fn active_profile_id(mut self, profile_id: Option) -> Self { + self.active_profile_id = profile_id; + self + } + + /// Binds the active profile's SOUL.md as the session identity override. + pub fn personality_soul_md(mut self, soul_md: Option) -> Self { + self.personality_soul_md = soul_md; + self + } + + /// Binds the active profile's curated MEMORY.md to the frozen session + /// prompt. `None` keeps the legacy workspace-root fallback. + pub fn personality_memory_md(mut self, memory_md: Option) -> Self { + self.personality_memory_md = memory_md; + self + } + + pub fn profile_memory_storage( + mut self, + memory_subdir: String, + session_raw_subdir: String, + ) -> Self { + self.memory_subdir = Some(memory_subdir); + self.session_raw_subdir = Some(session_raw_subdir); + self + } + /// Sets the skills available to the agent. pub fn workflows(mut self, skills: Vec) -> Self { self.workflows = Some(skills); @@ -467,6 +527,10 @@ impl AgentBuilder { .workspace_dir .unwrap_or_else(|| std::path::PathBuf::from(".")); let action_dir = self.action_dir.unwrap_or_else(|| workspace_dir.clone()); + let memory_subdir = self.memory_subdir.unwrap_or_else(|| "memory".to_string()); + let session_raw_subdir = self + .session_raw_subdir + .unwrap_or_else(|| "session_raw".to_string()); Ok(Agent { turn_model_source, @@ -478,6 +542,7 @@ impl AgentBuilder { memory: self .memory .ok_or_else(|| anyhow::anyhow!("memory is required"))?, + shared_experience_memory: self.shared_experience_memory, tool_dispatcher: std::sync::Arc::from( self.tool_dispatcher .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?, @@ -491,6 +556,7 @@ impl AgentBuilder { temperature: self.temperature.unwrap_or(0.7), workspace_dir, action_dir, + workspace_descriptor: self.workspace_descriptor, workflows: self.workflows.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, @@ -510,6 +576,11 @@ impl AgentBuilder { // `refresh_delegation_tools` to re-resolve the agent's // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), + active_profile_id: self.active_profile_id, + personality_soul_md: self.personality_soul_md, + personality_memory_md: self.personality_memory_md, + memory_subdir, + session_raw_subdir, session_transcript_path: None, persisted_transcript_messages: Vec::new(), session_key: { diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 074c756612..0ac6368dfd 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -426,8 +426,9 @@ impl Agent { return false; } + let session_raw_dir = self.workspace_dir.join(&self.session_raw_subdir); let Some(path) = - super::transcript::find_root_transcript_for_thread(&self.workspace_dir, thread_id) + super::transcript::find_root_transcript_for_thread_in_dir(&session_raw_dir, thread_id) else { log::debug!( "[web-channel] no root session_raw transcript for thread={thread_id} — \ diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index fd73cd211f..31c76a5bb6 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1177,8 +1177,9 @@ pub fn read_transcript_display(path: &Path) -> Result Ok(DisplaySessionTranscript { meta, records }) } -/// Find the newest root `session_raw/*.jsonl` transcript whose metadata -/// declares `thread_id`. +/// Find the newest root transcript whose metadata declares `thread_id`, across +/// the shared `session_raw/` store and every profile-scoped +/// `session_raw-/` store. /// /// Root transcripts live directly under `session_raw/` and do not carry /// the `__` separator used for sub-agent siblings. This helper is the @@ -1186,13 +1187,37 @@ pub fn read_transcript_display(path: &Path) -> Result /// transcript without accidentally folding delegated worker transcripts /// into the main chat timeline. pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> Option { + raw_session_dirs(workspace_dir) + .into_iter() + .filter_map(|raw_dir| find_root_transcript_for_thread_in_dir(&raw_dir, thread_id)) + .max_by(|left, right| left.file_name().cmp(&right.file_name())) +} + +fn raw_session_dirs(workspace_dir: &Path) -> Vec { + let mut raw_dirs = vec![raw_session_dir(workspace_dir)]; + if let Ok(entries) = fs::read_dir(workspace_dir) { + raw_dirs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix("session_raw-") + .is_some_and(|suffix| !suffix.is_empty()) + }) + })); + } + raw_dirs.sort(); + raw_dirs +} + +pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Option { let thread_id = thread_id.trim(); if thread_id.is_empty() { return None; } - let raw_dir = raw_session_dir(workspace_dir); - let entries = fs::read_dir(&raw_dir).ok()?; + let entries = fs::read_dir(raw_dir).ok()?; let mut matches: Vec = entries .flatten() .map(|entry| entry.path()) @@ -1335,39 +1360,41 @@ pub fn read_thread_usage_summary( return None; } - let raw_dir = raw_session_dir(workspace_dir); - let entries = fs::read_dir(&raw_dir).ok()?; - // Single scan: split the thread's transcripts into root (orchestrator) and // `__` sub-agent files. Root totals stay the parent's; sub-agent files are // grouped by archetype for the per-agent breakdown. let mut root_matches: Vec = Vec::new(); let mut sub_matches: Vec = Vec::new(); - for path in entries.flatten().map(|entry| entry.path()) { - if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { - continue; - } - let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + for raw_dir in raw_session_dirs(workspace_dir) { + let Ok(entries) = fs::read_dir(&raw_dir) else { continue; }; - let is_subagent = stem.contains("__"); - let matches_thread = read_transcript_meta_only(&path) - .map(|m| m.thread_id.as_deref() == Some(thread_id)) - .unwrap_or(false); - if !matches_thread { - continue; - } - if is_subagent { - sub_matches.push(path); - } else { - root_matches.push(path); + for path in entries.flatten().map(|entry| entry.path()) { + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let is_subagent = stem.contains("__"); + let matches_thread = read_transcript_meta_only(&path) + .map(|m| m.thread_id.as_deref() == Some(thread_id)) + .unwrap_or(false); + if !matches_thread { + continue; + } + if is_subagent { + sub_matches.push(path); + } else { + root_matches.push(path); + } } } if root_matches.is_empty() && sub_matches.is_empty() { return None; } - root_matches.sort(); + root_matches.sort_by(|left, right| left.file_name().cmp(&right.file_name())); let mut summary = ThreadUsageSummary::default(); for path in &root_matches { @@ -1439,7 +1466,11 @@ pub fn read_thread_usage_summary( /// own key so collisions are effectively impossible. pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result { let raw_dir = raw_session_dir(workspace_dir); - fs::create_dir_all(&raw_dir) + resolve_keyed_transcript_path_in_dir(&raw_dir, stem) +} + +pub fn resolve_keyed_transcript_path_in_dir(raw_dir: &Path, stem: &str) -> Result { + fs::create_dir_all(raw_dir) .with_context(|| format!("create session_raw dir {}", raw_dir.display()))?; let sanitized = sanitize_stem(stem); Ok(raw_dir.join(format!("{sanitized}.jsonl"))) @@ -1494,8 +1525,19 @@ pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Re /// The fallback is one-release transitional and can be removed once /// existing transcripts have rolled forward. pub fn find_latest_transcript(workspace_dir: &Path, agent_name: &str) -> Option { + find_latest_transcript_in_subdir(workspace_dir, "session_raw", agent_name) +} + +/// Find the most recent transcript inside a session's configured raw subtree. +/// Scoped profile sessions must never fall back to shared transcripts; the +/// legacy date-grouped/markdown fallback applies only to `session_raw`. +pub fn find_latest_transcript_in_subdir( + workspace_dir: &Path, + session_raw_subdir: &str, + agent_name: &str, +) -> Option { let sanitized = sanitize_agent_name(agent_name); - let raw_root = workspace_dir.join("session_raw"); + let raw_root = workspace_dir.join(session_raw_subdir); let sessions_root = workspace_dir.join("sessions"); // Primary path: flat session_raw/ directory. The stem-suffix scan @@ -1507,6 +1549,10 @@ pub fn find_latest_transcript(workspace_dir: &Path, agent_name: &str) -> Option< } } + if session_raw_subdir != "session_raw" { + return None; + } + // Fallback: legacy date-grouped layout (one-release migration // window). Today first, then yesterday — matches the previous // behaviour so we don't regress while users still have files in diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index ed649b63c4..260ff125de 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -329,6 +329,23 @@ fn find_root_transcript_for_thread_skips_subagent_siblings() { .ends_with("1714000000_orchestrator_thread-abc.jsonl")); } +#[test] +fn find_root_transcript_for_thread_scans_profile_scoped_raw_dirs() { + let dir = TempDir::new().unwrap(); + let scoped_raw = dir.path().join("session_raw-alice"); + fs::create_dir_all(&scoped_raw).unwrap(); + + let mut meta = sample_meta(); + meta.thread_id = Some("thread-scoped".into()); + let expected = scoped_raw.join("1714000000_orchestrator_thread-scoped.jsonl"); + write_transcript(&expected, &sample_messages(), &meta, None).unwrap(); + + assert_eq!( + find_root_transcript_for_thread(dir.path(), "thread-scoped"), + Some(expected) + ); +} + #[test] fn find_latest_falls_back_to_legacy_ddmmyyyy_raw_dir() { // Pre-migration transcript at session_raw/DDMMYYYY/main_*.jsonl @@ -896,6 +913,31 @@ fn read_thread_usage_summary_sums_multiple_transcripts() { assert_eq!(s.turn_count, 2); } +#[test] +fn read_thread_usage_summary_scans_profile_scoped_raw_dirs() { + let ws = TempDir::new().unwrap(); + let raw = ws.path().join("session_raw-alice"); + std::fs::create_dir_all(&raw).unwrap(); + let mut meta = sample_meta(); + meta.thread_id = Some("thr-scoped-usage".into()); + meta.input_tokens = 321; + meta.output_tokens = 45; + meta.turn_count = 2; + write_transcript( + &raw.join("1700000000_main.jsonl"), + &sample_messages(), + &meta, + None, + ) + .unwrap(); + + let summary = read_thread_usage_summary(ws.path(), "thr-scoped-usage") + .expect("scoped usage summary present"); + assert_eq!(summary.input_tokens, 321); + assert_eq!(summary.output_tokens, 45); + assert_eq!(summary.turn_count, 2); +} + #[test] fn read_thread_usage_summary_none_for_unknown_thread() { let ws = TempDir::new().unwrap(); diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index dfbcf90b4c..d820ebe610 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -244,6 +244,7 @@ impl Agent { let limits = self.config.resolved_memory_limits(); let tree_root_summaries = collect_tree_root_summaries( &self.workspace_dir, + &self.memory_subdir, limits.per_namespace_max_chars, limits.total_tree_max_chars, ); @@ -321,12 +322,11 @@ impl Agent { include_memory_md: !self.omit_memory_md, curated_snapshot: None, user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(), - // TODO(phase-2): Wire personality context into the live agent turn. - // Currently personalities only take effect during delegate_to_personality sub-agent runs. - // To activate: load the active profile via AgentProfileStore::resolve(), build - // PersonalityContext::from_profile(), and populate these fields. - personality_soul_md: None, // TODO: personality_ctx.soul_md_override - personality_memory_md: None, // TODO: personality_ctx.memory_md_override + // Profile SOUL.md and curated MEMORY.md are bound at session + // construction so the normal identity/user-files sections use + // them instead of their workspace-root fallbacks. + personality_soul_md: self.personality_soul_md.clone(), + personality_memory_md: self.personality_memory_md.clone(), personality_roster: vec![], // TODO: build_personality_roster(&workspace_dir) agents_md_global: agents_md.global, agents_md_local: agents_md.local, diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index f6756db6e3..91f3a70057 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -11,7 +11,8 @@ use crate::openhuman::agent::harness::fork_context::ParentExecutionContext; use crate::openhuman::agent::hooks::{self, TurnContext}; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::agent_experience::{ - prepend_experience_block, render_experience_hits, AgentExperienceStore, ExperienceQuery, + prepend_experience_block, render_experience_hits, retrieve_across_stores, AgentExperienceStore, + ExperienceQuery, }; use crate::openhuman::agent_memory::memory_loader::collect_recall_citations; use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; @@ -1322,6 +1323,10 @@ impl Agent { channel: self.event_channel().to_string(), agent_definition_id: self.agent_definition_id.clone(), }), + // Section D: forward the session's per-profile workspace + // descriptor (if any) so the top-level chat turn's acting + // tools default their cwd to the profile's dedicated dir. + workspace_descriptor: self.workspace_descriptor.clone(), }), ) .await; @@ -1664,7 +1669,10 @@ impl Agent { .iter() .map(|spec| spec.name.clone()) .collect(); - let store = AgentExperienceStore::new(self.memory.clone()); + let mut stores = vec![AgentExperienceStore::new(self.memory.clone())]; + if let Some(shared_memory) = &self.shared_experience_memory { + stores.push(AgentExperienceStore::new(shared_memory.clone())); + } let query = ExperienceQuery { query: user_message.to_string(), tools, @@ -1672,10 +1680,14 @@ impl Agent { agent_id: Some(self.agent_definition_id.clone()).filter(|id| !id.trim().is_empty()), entrypoint: Some(self.event_channel.clone()) .filter(|entrypoint| !entrypoint.trim().is_empty()), + // 1c — partition recall by the active profile: this turn sees records + // stamped with its profile plus unstamped legacy records, and never a + // sibling profile's. `None` (profile-less) recalls the whole pool. + profile_id: self.active_profile_id.clone(), max_hits: MAX_EXPERIENCE_HITS, }; - match store.retrieve(query).await { + match retrieve_across_stores(&stores, query).await { Ok(hits) => { let matched_hits: Vec<_> = hits .into_iter() diff --git a/src/openhuman/agent/harness/session/turn/graph.rs b/src/openhuman/agent/harness/session/turn/graph.rs index 95afaa8f68..464098cb9f 100644 --- a/src/openhuman/agent/harness/session/turn/graph.rs +++ b/src/openhuman/agent/harness/session/turn/graph.rs @@ -72,6 +72,12 @@ pub(crate) struct ChatTurnGraph { /// The agent's builder-configured tool policy + session context, enforced at /// the tool boundary. `None` when the session has no explicit policy. pub tool_policy: Option, + /// Optional per-profile workspace descriptor (section D of agent-profile + /// homes). `Some` when the session's active profile opted into a dedicated + /// workspace — acting tools then resolve their default cwd to + /// `/profiles/` via `ToolExecutionContext.workspace`. `None` + /// (the common case) keeps the shared-`action_dir` cwd behaviour. + pub workspace_descriptor: Option, } /// Drive the chat turn graph: a thin wrapper over the shared tinyagents seam @@ -121,8 +127,10 @@ pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result/profiles/` for a `dedicated_workspace` profile. + graph.workspace_descriptor, // Interactive chat turn — response caching MUST stay off so a live user // turn is never served a cached model response (correctness/safety). false, diff --git a/src/openhuman/agent/harness/session/turn/mod.rs b/src/openhuman/agent/harness/session/turn/mod.rs index ddd03491f5..8f1d5e9f00 100644 --- a/src/openhuman/agent/harness/session/turn/mod.rs +++ b/src/openhuman/agent/harness/session/turn/mod.rs @@ -159,23 +159,84 @@ Do not attempt to run them with `run_skill` — they have been removed. Tell the /// preset by [`crate::openhuman::config::schema::agent::AgentConfig::resolved_memory_limits`]. pub(super) fn collect_tree_root_summaries( workspace_dir: &std::path::Path, + memory_subdir: &str, per_namespace_cap: usize, total_cap: usize, ) -> Vec { - crate::openhuman::memory_tree::tree_runtime::store::collect_root_summaries_with_caps( - workspace_dir, - per_namespace_cap, - total_cap, - ) - .into_iter() - .map( - |(namespace, body, updated_at)| crate::openhuman::context::prompt::NamespaceSummary { - namespace, - body, - updated_at, - }, - ) - .collect() + let rows = if memory_subdir == "memory" { + crate::openhuman::memory_tree::tree_runtime::store::collect_root_summaries_with_caps( + workspace_dir, + per_namespace_cap, + total_cap, + ) + } else { + collect_profile_tree_root_summaries( + &workspace_dir.join(memory_subdir), + per_namespace_cap, + total_cap, + ) + }; + rows.into_iter() + .map( + |(namespace, body, updated_at)| crate::openhuman::context::prompt::NamespaceSummary { + namespace, + body, + updated_at, + }, + ) + .collect() +} + +/// Read TinyCortex root summaries from an already-resolved `memory-*` subtree. +/// TinyCortex's compatibility helper hardcodes `/memory`; dedicated +/// profiles instead supply `/memory-`, so scan that equivalent +/// namespace layout directly rather than falling back to shared memory. +fn collect_profile_tree_root_summaries( + memory_dir: &std::path::Path, + per_namespace_cap: usize, + total_cap: usize, +) -> Vec<(String, String, chrono::DateTime)> { + let Ok(entries) = std::fs::read_dir(memory_dir.join("namespaces")) else { + return Vec::new(); + }; + let mut roots: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let namespace = entry.file_name().to_string_lossy().into_owned(); + let raw = std::fs::read_to_string(entry.path().join("tree").join("root.md")).ok()?; + let node = tinycortex::memory::tree::runtime::store::parse_node_markdown_pub( + &raw, &namespace, "root", + ) + .ok()?; + Some((namespace, node)) + }) + .collect(); + roots.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut total_chars = 0usize; + let mut out = Vec::new(); + for (namespace, node) in roots { + if total_chars >= total_cap { + break; + } + let body = node.summary.trim(); + if body.is_empty() { + continue; + } + let remaining = total_cap.saturating_sub(total_chars); + let cap = per_namespace_cap.min(remaining); + let body_chars = body.chars().count(); + let rendered = if body_chars > cap { + let mut clipped: String = body.chars().take(cap).collect(); + clipped.push_str("\n\n[... truncated]"); + clipped + } else { + body.to_string() + }; + total_chars += rendered.chars().count(); + out.push((namespace, rendered, node.updated_at)); + } + out } /// Sanitize a learned memory entry before injecting into the system prompt. diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index b058469c29..3479e62ea5 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -20,7 +20,11 @@ impl Agent { /// /// Best-effort: failures are logged and silently ignored. pub(in super::super) fn try_load_session_transcript(&mut self) { - match transcript::find_latest_transcript(&self.workspace_dir, &self.agent_definition_name) { + match transcript::find_latest_transcript_in_subdir( + &self.workspace_dir, + &self.session_raw_subdir, + &self.agent_definition_name, + ) { Some(path) => { log::info!( "[transcript] found previous transcript path={}", @@ -502,7 +506,8 @@ impl Agent { Some(prefix) => format!("{}__{}", prefix, self.session_key), None => self.session_key.clone(), }; - match transcript::resolve_keyed_transcript_path(&self.workspace_dir, &stem) { + let session_raw_dir = self.workspace_dir.join(&self.session_raw_subdir); + match transcript::resolve_keyed_transcript_path_in_dir(&session_raw_dir, &stem) { Ok(path) => { log::info!( "[transcript] new session transcript path={}", diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 1dd30deab7..d33e7dab7f 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -15,13 +15,23 @@ impl Agent { /// Snapshot the parent's runtime so spawned sub-agents can read /// it via the [`harness::PARENT_CONTEXT`] task-local. pub(super) fn build_parent_execution_context(&self) -> harness::ParentExecutionContext { - let workspace_descriptor = - harness::current_parent().and_then(|parent| parent.workspace_descriptor); + // Prefer an ambient `current_parent()` descriptor (a nested subagent + // inherits its enclosing worktree/profile workspace threaded down the + // spawn chain). Fall back to THIS session agent's own descriptor: on a + // ROOT chat turn `current_parent()` is `None`, so without the fallback a + // dedicated-workspace profile's descriptor (`/profiles/`, + // set on the Agent at build time) would never reach delegated subagents + // spawned via `spawn_subagent` / `spawn_async_subagent`, and they'd drop + // to the shared `action_dir` — the profile isolation would silently not + // apply to common delegated writes. + let workspace_descriptor = harness::current_parent() + .and_then(|parent| parent.workspace_descriptor) + .or_else(|| self.workspace_descriptor.clone()); if let Some(descriptor) = workspace_descriptor.as_ref() { tracing::debug!( root = %descriptor.root.display(), policy_id = %descriptor.policy_id, - "[agent_loop] inheriting ambient workspace descriptor for parent context snapshot" + "[agent_loop] snapshotting workspace descriptor for parent context (ambient or own)" ); } let allowed_subagent_ids = crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global() @@ -326,7 +336,28 @@ impl Agent { w.dir_name.clone() } }; - let latest = crate::openhuman::skills::load_workflow_metadata(&self.workspace_dir); + // Keep the mid-session refresh consistent with the initial catalog + // (built in the session factory): include the active profile's private + // skills root so profile-local installs are tracked/announced too. `None` + // for the profile-less session reproduces the prior behaviour. + let profile_skills_root = self.active_profile_id.as_deref().and_then(|id| { + crate::openhuman::profiles::profile_skills_root(&self.workspace_dir, id) + }); + // An invalid/absent active profile id silently falls back to shared + // discovery. Log the branch id-free (boolean only, never the profile id or + // resolved path) per the observability convention for new/changed flows. + let profile_local_skills_active = profile_skills_root.is_some(); + log::debug!( + "[agent_loop] refreshing installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active})" + ); + let latest = crate::openhuman::skills::load_workflow_metadata_for_profile( + &self.workspace_dir, + profile_skills_root.as_deref(), + ); + log::debug!( + "[agent_loop] refreshed installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active}, workflow_count={})", + latest.len() + ); let current_ids: std::collections::HashSet = self.workflows.iter().map(&id_of).collect(); let latest_ids: std::collections::HashSet = latest.iter().map(&id_of).collect(); diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5569f14476..175dfe97c0 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -7,6 +7,9 @@ use crate::openhuman::agent::tool_policy::{ GeneratedToolRuntimeContext, GeneratedToolRuntimeRisk, ToolPolicy, ToolPolicyDecision, ToolPolicyRequest, }; +use crate::openhuman::agent_experience::{ + AgentExperience, AgentExperienceStore, ExperienceOutcome, ExperienceSource, +}; use crate::openhuman::agent_memory::memory_loader::MemoryLoader; use crate::openhuman::inference::provider::{ ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo, @@ -489,7 +492,49 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { ); let long = "x".repeat(500); assert_eq!(sanitize_learned_entry(&long).chars().count(), 200); - assert!(collect_tree_root_summaries(agent.workspace_dir(), 8_000, 32_000).is_empty()); + assert!(collect_tree_root_summaries(agent.workspace_dir(), "memory", 8_000, 32_000).is_empty()); +} + +#[test] +fn build_parent_context_propagates_own_descriptor_on_root_turn() { + // Regression (PR #5118 review, Codex): on a ROOT chat turn `current_parent()` + // is `None`, so the parent snapshot must fall back to the agent's OWN + // descriptor. Without it, a dedicated-workspace profile's descriptor never + // reaches subagents spawned via spawn_subagent/spawn_async_subagent, and they + // silently fall back to the shared action_dir instead of + // `/profiles/`. + let descriptor = tinyagents::harness::workspace::WorkspaceDescriptor::new( + std::path::PathBuf::from("/tmp/act/profiles/alice"), + ) + .with_policy_id("openhuman.profile:alice"); + + let mut agent = make_agent(None); + // No ambient parent context is installed in this test, so current_parent() + // is None — exactly the root-turn scenario. + agent.workspace_descriptor = Some(descriptor); + + let parent = agent.build_parent_execution_context(); + assert_eq!( + parent.workspace_descriptor.as_ref().map(|d| d.root.clone()), + Some(std::path::PathBuf::from("/tmp/act/profiles/alice")), + "root turn must propagate the agent's own profile descriptor to spawned subagents" + ); + assert_eq!( + parent + .workspace_descriptor + .as_ref() + .map(|d| d.policy_id.clone()), + Some("openhuman.profile:alice".to_string()), + ); +} + +#[test] +fn build_parent_context_has_no_descriptor_without_profile_or_parent() { + // A profile-less root turn (no ambient parent, no own descriptor) keeps the + // snapshot's descriptor `None` so shared-action_dir behaviour is unchanged. + let agent = make_agent(None); + let parent = agent.build_parent_execution_context(); + assert!(parent.workspace_descriptor.is_none()); } #[test] @@ -528,13 +573,50 @@ fn collect_tree_root_summaries_maps_namespace_body_and_timestamp() { }; write_node(&config, &node).unwrap(); - let summaries = collect_tree_root_summaries(&workspace, 8_000, 32_000); + let summaries = collect_tree_root_summaries(&workspace, "memory", 8_000, 32_000); assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].namespace, "activities"); assert_eq!(summaries[0].body, summary); assert_eq!(summaries[0].updated_at, updated_at); } +#[test] +fn collect_tree_root_summaries_reads_only_profile_memory_subtree() { + use crate::openhuman::config::Config; + use crate::openhuman::memory_tree::tree_runtime::store::write_node; + use crate::openhuman::memory_tree::tree_runtime::types::{ + derive_parent_id, estimate_tokens, level_from_node_id, TreeNode, + }; + + let tmp = tempfile::TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let config = Config { + workspace_dir: workspace.clone(), + ..Config::default() + }; + let now = chrono::Utc::now(); + let node = TreeNode { + node_id: "root".into(), + namespace: "private".into(), + level: level_from_node_id("root"), + parent_id: derive_parent_id("root"), + summary: "Alice-only context".into(), + token_count: estimate_tokens("Alice-only context"), + child_count: 0, + created_at: now, + updated_at: now, + metadata: None, + }; + write_node(&config, &node).unwrap(); + std::fs::rename(workspace.join("memory"), workspace.join("memory-alice")).unwrap(); + + assert!(collect_tree_root_summaries(&workspace, "memory", 8_000, 32_000).is_empty()); + let summaries = collect_tree_root_summaries(&workspace, "memory-alice", 8_000, 32_000); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].body, "Alice-only context"); +} + #[tokio::test] async fn transcript_roundtrip_work() { let mut agent = make_agent(None); @@ -590,6 +672,54 @@ async fn transcript_resume_is_bounded_by_max_history_messages() { assert_eq!(cached[4].content, "a7"); } +#[tokio::test] +async fn transcript_resume_uses_profile_scoped_raw_directory() { + let mut shared = make_agent(None); + shared.persist_session_transcript( + &[ + ChatMessage::system("shared-system"), + ChatMessage::user("shared-user"), + ], + 0, + 0, + 0, + 0.0, + None, + ); + + let mut profile = make_agent(None); + profile.workspace_dir = shared.workspace_dir.clone(); + profile.agent_definition_name = shared.agent_definition_name.clone(); + profile.session_raw_subdir = "session_raw-alice".to_string(); + profile.persist_session_transcript( + &[ + ChatMessage::system("profile-system"), + ChatMessage::user("profile-user"), + ], + 0, + 0, + 0, + 0.0, + None, + ); + + let mut resumed = make_agent(None); + resumed.workspace_dir = shared.workspace_dir.clone(); + resumed.agent_definition_name = shared.agent_definition_name.clone(); + resumed.session_raw_subdir = "session_raw-alice".to_string(); + resumed.try_load_session_transcript(); + + let cached = resumed + .cached_transcript_messages + .expect("profile transcript"); + assert!(cached + .iter() + .any(|message| message.content == "profile-user")); + assert!(cached + .iter() + .all(|message| message.content != "shared-user")); +} + // NOTE: The `execute_tool_call_*` tests that exercised the legacy per-call // direct tool executor (`Agent::execute_tool_call`) were removed during the // tinyagents migration. The direct executor and its test-only parity shim @@ -1946,6 +2076,62 @@ fn make_real_memory(workspace: &std::path::Path) -> Arc { Arc::new(UnifiedMemory::new(workspace, Arc::new(NoopEmbedding), None).unwrap()) } +#[tokio::test] +async fn dedicated_profile_experience_recall_merges_shared_legacy_store() { + let tmp = tempfile::TempDir::new().unwrap(); + let dedicated = make_real_memory(&tmp.path().join("dedicated")); + let shared = make_real_memory(&tmp.path().join("shared")); + AgentExperienceStore::new(shared.clone()) + .put(AgentExperience { + id: "legacy-shared-deploy".into(), + created_at_ms: 0, + updated_at_ms: 0, + source: ExperienceSource::ToolLoop, + agent_id: None, + entrypoint: None, + profile_id: None, + task_fingerprint: "deploy-rust-service".into(), + task_summary: "Deploy the Rust service safely".into(), + tools_used: vec![], + tool_sequence: vec![], + outcome: ExperienceOutcome::Success, + error_class: None, + lesson: "Legacy shared deployment guidance".into(), + reuse_hint: "Check the release health endpoint".into(), + avoid_hint: None, + confidence: 0.9, + tags: vec![], + payload_hash: None, + dismissed: false, + }) + .await + .unwrap(); + + let agent = Agent::builder() + .provider(Box::new(DummyProvider)) + .tools(vec![]) + .memory(dedicated) + .shared_experience_memory(Some(shared)) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(tmp.path().to_path_buf()) + .event_context("profile-experience-test", "web_chat") + .active_profile_id(Some("alice".into())) + .profile_memory_storage("memory-alice".into(), "session_raw-alice".into()) + .learning_enabled(true) + .build() + .unwrap(); + + let enriched = agent + .inject_agent_experience_context( + "How should I deploy the Rust service?", + "original prompt".into(), + ) + .await; + + assert!(enriched.contains("Legacy shared deployment guidance")); + assert!(enriched.contains("original prompt")); +} + #[tokio::test] async fn fetch_learned_context_returns_empty_when_both_flags_off() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index c6a6bd71f6..115388386f 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -44,12 +44,16 @@ pub struct Agent { /// provider in the main agent's chat requests. pub(super) visible_tool_specs: Arc>, /// When non-empty, only these tool names are visible in the main - /// agent's prompt and callable by the main agent. Sub-agents ignore - /// this filter — they apply per-definition whitelists in the runner. + /// agent's prompt and callable by the main agent. Sub-agents intersect + /// their per-definition scopes with the effective parent-visible set. /// Empty = no filter (all tools visible, backward compat). pub(super) visible_tool_names: std::collections::HashSet, pub(super) tool_policy_session: ToolPolicySession, pub(super) memory: Arc, + /// Shared memory store retained alongside a dedicated profile store so live + /// experience recall can merge unstamped legacy guidance. `None` for the + /// shared/default memory path. + pub(super) shared_experience_memory: Option>, // `Arc` (not `Box`) so the tinyagents turn path can hold a cheap clone of // the dispatcher without borrowing the `Agent` while session state mutates. pub(super) tool_dispatcher: Arc, @@ -64,6 +68,12 @@ pub struct Agent { pub(super) temperature: f64, pub(super) workspace_dir: std::path::PathBuf, pub(super) action_dir: std::path::PathBuf, + /// Optional per-profile workspace descriptor. When set (a profile with + /// `dedicated_workspace` opted in), it is threaded into the top-level chat + /// turn so acting tools (shell/file/git) resolve their default cwd to + /// `/profiles/` instead of the shared `action_dir`. `None` + /// (the common case) preserves the shared-cwd behaviour unchanged. + pub(super) workspace_descriptor: Option, pub(super) workflows: Vec, /// Agent workflows discovered at session start. pub(super) auto_save: bool, @@ -121,6 +131,32 @@ pub struct Agent { /// /// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry pub(super) agent_definition_id: String, + /// Id of the agent profile this session runs under, when the turn was + /// launched with an active profile (`profiles` domain). `None` for the + /// default (profile-less) session — the byte-identical legacy path. + /// + /// Set once at build time from the resolved [`AgentProfile`] and never + /// rewritten. Consumed by the profile-scoped agent-experience capture + + /// retrieval (1c): records are stamped with this id and only records + /// matching it (plus unstamped legacy records) are recalled. The same + /// active id also arms the tool-layer sibling-workspace guard, regardless + /// of whether this profile uses a dedicated cwd. + pub(super) active_profile_id: Option, + /// Profile-local SOUL.md resolved when the session is built. When set, + /// IdentitySection uses it instead of the workspace-root identity. + pub(super) personality_soul_md: Option, + /// Profile-local curated MEMORY.md resolved when the session is built. + /// `None` preserves the workspace-root MEMORY.md fallback. + pub(super) personality_memory_md: Option, + /// Profile-selected memory subtree name (`memory`, `memory-`, or a + /// legacy numeric suffix). Used for memory-tree reads and paired with the + /// profile-specific transcript directory below. + pub(super) memory_subdir: String, + /// Profile-selected JSONL transcript subdirectory (`session_raw` or + /// `session_raw-`). It is resolved against the current `workspace_dir` + /// at I/O time so relocating an agent does not leave transcripts pinned to + /// its original workspace while dedicated-memory profiles remain isolated. + pub(super) session_raw_subdir: String, /// Resolved filesystem path for this session's transcript file. /// Set on first write, reused for subsequent **appends** within the /// same session. @@ -336,6 +372,7 @@ pub struct AgentBuilder { /// When set, restricts which tools the main agent sees/calls. pub(super) visible_tool_names: Option>, pub(super) memory: Option>, + pub(super) shared_experience_memory: Option>, pub(super) prompt_builder: Option, pub(super) tool_dispatcher: Option>, pub(super) memory_loader: Option>, @@ -350,6 +387,9 @@ pub struct AgentBuilder { pub(super) temperature: Option, pub(super) workspace_dir: Option, pub(super) action_dir: Option, + /// Optional per-profile workspace descriptor forwarded to [`Agent`] at build + /// time. Defaults to `None` (shared `action_dir` cwd). + pub(super) workspace_descriptor: Option, pub(super) workflows: Option>, /// Agent workflows to surface in the prompt. Populated from `load_workflows` /// at session start; defaults to empty when not explicitly set. @@ -360,6 +400,16 @@ pub struct AgentBuilder { pub(super) event_session_id: Option, pub(super) event_channel: Option, pub(super) agent_definition_name: Option, + /// Forwarded to [`Agent::active_profile_id`] at `build()` time. `None` + /// (default) means the profile-less session; the profile-launching callers + /// (web chat, task dispatcher, cron) set the active profile id here. + pub(super) active_profile_id: Option, + /// Forwarded to [`Agent::personality_soul_md`] at build time. + pub(super) personality_soul_md: Option, + /// Forwarded to [`Agent::personality_memory_md`] at build time. + pub(super) personality_memory_md: Option, + pub(super) memory_subdir: Option, + pub(super) session_raw_subdir: Option, /// Directory chain of parent session keys for a sub-agent. `None` /// (default) means this is a root session — its transcript lands /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 2c3659c0e0..991c0b72d1 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -643,6 +643,15 @@ async fn run_typed_mode( if definition.id == "tools_agent" { allowed_indices.retain(|&i| parent.all_tools[i].category() != ToolCategory::Workflow); } + // A child may only narrow the parent's effective tool surface, never widen + // it back to `all_tools`. This preserves profile allowlists and channel + // policy across delegation (including wildcard child definitions and + // force-included `extra_tools`). Empty retains legacy "unknown" semantics. + super::super::tool_prep::retain_parent_visible_tool_indices( + &mut allowed_indices, + &parent.all_tools, + &parent.visible_tool_names, + ); if is_integrations_agent_with_toolkit { if let Some(tk) = toolkit_filter { @@ -791,6 +800,14 @@ async fn run_typed_mode( } } + // Dynamic Composio action tools are effectful too. Do not let delegation + // synthesize one that the parent profile/policy did not expose. Internal + // runner-only tools (such as extract_from_result below) are added after + // this intersection and cannot access the filesystem/process surface. + if !parent.visible_tool_names.is_empty() { + dynamic_tools.retain(|tool| parent.visible_tool_names.contains(tool.name())); + } + // ── Progressive-disclosure handoff cache ─────────────────────────── let handoff_cache: Option> = if is_integrations_agent_with_toolkit { let cache = Arc::new(ResultHandoffCache::new()); diff --git a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs index e0f5309558..892cb14b8f 100644 --- a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs +++ b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs @@ -205,6 +205,20 @@ pub(super) fn filter_tool_indices( .collect() } +/// Intersect a child definition's tool indices with the tools the parent turn +/// actually exposes. An empty parent set is the legacy "unknown/unrestricted" +/// sentinel used by internal callers and older tests. +pub(super) fn retain_parent_visible_tool_indices( + indices: &mut Vec, + parent_tools: &[Box], + parent_visible: &std::collections::HashSet, +) { + if parent_visible.is_empty() { + return; + } + indices.retain(|&index| parent_visible.contains(parent_tools[index].name())); +} + pub(super) fn disallowed_tool_matches(disallowed: &[String], name: &str) -> bool { disallowed.iter().any(|entry| { if let Some(prefix) = entry.strip_suffix('*') { @@ -340,6 +354,17 @@ mod recovery_visibility_tests { ); assert!(!names(&idx, &t).contains(&RECOVERY_TOOL_NAME.to_string())); } + + #[test] + fn parent_visibility_caps_wildcard_child_scope() { + let t = tools(); + let mut idx = filter_tool_indices(&t, &ToolScope::Wildcard, &[], None); + let parent_visible = ["current_time".to_string()].into_iter().collect(); + + retain_parent_visible_tool_indices(&mut idx, &t, &parent_visible); + + assert_eq!(names(&idx, &t), vec!["current_time".to_string()]); + } } // ── Prompt loading ────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index f148854f0c..8536f01fd4 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -33,7 +33,9 @@ use async_trait::async_trait; use serde_json::json; -use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_background}; +use crate::openhuman::skill_runtime::{ + await_run_outcome, spawn_workflow_run_background_with_profile, +}; use crate::openhuman::skills::schemas::resolve_workspace_dir; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; @@ -208,9 +210,15 @@ fn outcome_to_result( /// `run_workflow` — orchestrator-callable spawn + inline await of another /// workflow. pub struct RunWorkflowTool { + /// Full active profile context inherited by the autonomous workflow agent. + active_profile: Option, /// Per-profile allowlist of runnable workflow `dir_name` slugs. `None` /// (the default) means every installed workflow may be run. skill_allowlist: Option>, + /// Active profile's private skills root + /// (`/personalities//skills/`). Resolves + implicitly allows + /// the owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl Default for RunWorkflowTool { @@ -222,10 +230,20 @@ impl Default for RunWorkflowTool { impl RunWorkflowTool { pub fn new() -> Self { Self { + active_profile: None, skill_allowlist: None, + profile_skills_root: None, } } + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile = profile; + self + } + /// Restrict which workflows this tool may run to a per-profile allowlist. pub fn with_skill_allowlist( mut self, @@ -234,6 +252,13 @@ impl RunWorkflowTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills so a + /// turn under profile P can run P's own skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -308,7 +333,12 @@ impl Tool for RunWorkflowTool { } }; if let Some(allow) = &self.skill_allowlist { - if !allow.contains(&workflow_id) { + // Profile-local skills are implicitly allowed for their owner (they + // bypass `allowed_skills`), mirroring `list_workflows`. + let profile_local = crate::openhuman::skills::profile_local_skill_ids( + self.profile_skills_root.as_deref(), + ); + if !profile_local.contains(&workflow_id) && !allow.contains(&workflow_id) { log::debug!("[profiles] run_workflow blocked by profile allowlist: {workflow_id}"); return Ok(ToolResult::error(format!( "run_workflow: workflow `{workflow_id}` is not available to the active agent profile" @@ -321,7 +351,14 @@ impl Tool for RunWorkflowTool { // Fire-and-forget: only the spawn backstop applies — no await, so no // re-entrancy/nesting slot to take. if wait_seconds == 0 { - return match spawn_workflow_run_background(workflow_id.clone(), inputs).await { + return match spawn_workflow_run_background_with_profile( + workflow_id.clone(), + inputs, + self.profile_skills_root.clone(), + self.active_profile.clone(), + ) + .await + { // Count only spawns that actually start against the backstop — // unknown-workflow / bad-input rejections (the Err arm) must not // burn the budget, or rejected calls accumulate and trip the @@ -355,7 +392,14 @@ impl Tool for RunWorkflowTool { Err(e) => return Ok(ToolResult::error(format!("run_workflow: {e}"))), }; - let started = match spawn_workflow_run_background(workflow_id.clone(), inputs).await { + let started = match spawn_workflow_run_background_with_profile( + workflow_id.clone(), + inputs, + self.profile_skills_root.clone(), + self.active_profile.clone(), + ) + .await + { Ok(s) => { if let Err(e) = guard::account_spawn() { return Ok(ToolResult::error(format!("run_workflow: {e}"))); @@ -385,7 +429,11 @@ impl Tool for RunWorkflowTool { } /// `await_workflow` — re-attach to a detached run by `run_id` and wait. -pub struct AwaitWorkflowTool; +pub struct AwaitWorkflowTool { + active_profile_id: Option, + skill_allowlist: Option>, + profile_skills_root: Option, +} impl Default for AwaitWorkflowTool { fn default() -> Self { @@ -395,8 +443,44 @@ impl Default for AwaitWorkflowTool { impl AwaitWorkflowTool { pub fn new() -> Self { - Self + Self { + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, + } } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist( + mut self, + allowlist: Option>, + ) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } +} + +fn run_visible_to_profile( + run: &crate::openhuman::skills::run_log::ScannedRun, + active_profile_id: Option<&str>, + skill_allowlist: Option<&std::collections::HashSet>, + profile_local_ids: &std::collections::HashSet, +) -> bool { + run.profile_id.as_deref() == active_profile_id + && (profile_local_ids.contains(&run.workflow_id) + || skill_allowlist.is_none_or(|allowlist| allowlist.contains(&run.workflow_id))) } #[async_trait] @@ -447,16 +531,27 @@ impl Tool for AwaitWorkflowTool { let wait_seconds = parse_wait_seconds(&args); let workspace = resolve_workspace_dir().await; - let log_path = - match crate::openhuman::skills::run_log::find_run_log_path(&workspace, &run_id) { - Some(p) => p, - None => { - return Ok(ToolResult::error(format!( - "await_workflow: no run found for run_id `{run_id}` (it may not exist or \ - hasn't started writing its log yet)" - ))); - } - }; + let profile_local_ids = + crate::openhuman::skills::profile_local_skill_ids(self.profile_skills_root.as_deref()); + let visible_run = + crate::openhuman::skills::run_log::scan_runs(&workspace, None, usize::MAX) + .into_iter() + .find(|run| { + run.run_id == run_id + && run_visible_to_profile( + run, + self.active_profile_id.as_deref(), + self.skill_allowlist.as_ref(), + &profile_local_ids, + ) + }); + let Some(visible_run) = visible_run else { + return Ok(ToolResult::error(format!( + "await_workflow: no run found for run_id `{run_id}` (it may not exist, is not \ + available to the active profile, or hasn't started writing its log yet)" + ))); + }; + let log_path = std::path::PathBuf::from(&visible_run.log_path); // Take an await slot so the LLM can't stack unbounded waits or // double-await the same run; keyed by run_id. @@ -467,9 +562,12 @@ impl Tool for AwaitWorkflowTool { let outcome = await_run_outcome(&log_path, std::time::Duration::from_secs(wait_seconds)).await; - // workflow_id isn't carried on the handle here; the run_id is the - // stable key the caller holds, so echo that and leave workflow_id blank. - Ok(outcome_to_result(&run_id, "", &log_path, outcome)) + Ok(outcome_to_result( + &run_id, + &visible_run.workflow_id, + &log_path, + outcome, + )) } } @@ -523,6 +621,41 @@ mod tests { assert!(res.output().contains("run_id")); } + #[test] + fn detached_run_visibility_requires_profile_and_allowlist() { + let run = crate::openhuman::skills::run_log::ScannedRun { + run_id: "run-1".to_string(), + workflow_id: "private-flow".to_string(), + profile_id: Some("alice".to_string()), + started: String::new(), + status: "DONE".to_string(), + duration_ms: None, + finished: None, + log_path: "/tmp/run.log".to_string(), + }; + let allowed = ["private-flow".to_string()].into_iter().collect(); + let empty = std::collections::HashSet::new(); + + assert!(run_visible_to_profile( + &run, + Some("alice"), + Some(&allowed), + &empty + )); + assert!(!run_visible_to_profile( + &run, + Some("bob"), + Some(&allowed), + &empty + )); + assert!(!run_visible_to_profile( + &run, + Some("alice"), + Some(&empty), + &empty + )); + } + #[test] fn wait_seconds_defaults_and_clamps() { assert_eq!(parse_wait_seconds(&json!({})), DEFAULT_WAIT_SECONDS); diff --git a/src/openhuman/agent_experience/capture.rs b/src/openhuman/agent_experience/capture.rs index bf5aa7c625..facf395e20 100644 --- a/src/openhuman/agent_experience/capture.rs +++ b/src/openhuman/agent_experience/capture.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use crate::openhuman::agent::hooks::{PostTurnHook, ToolCallRecord, TurnContext}; use crate::openhuman::agent_experience::store::AgentExperienceStore; use crate::openhuman::agent_experience::types::{ - redact_text, stable_experience_id, AgentExperience, ExperienceOutcome, ExperienceSource, + redact_text, stable_experience_id, stable_experience_id_for_profile, AgentExperience, + ExperienceOutcome, ExperienceSource, }; use crate::openhuman::memory::Memory; @@ -14,18 +15,37 @@ const MAX_SUMMARY_CHARS: usize = 280; pub struct AgentExperienceCaptureHook { store: AgentExperienceStore, enabled: bool, + /// Profile the session runs under (1c). Stamped onto every captured record + /// so retrieval can partition by profile. `None` for the profile-less + /// session — those records stay unstamped (shared/legacy). + profile_id: Option, } impl AgentExperienceCaptureHook { pub fn new(memory: Arc, enabled: bool) -> Self { + Self::with_profile(memory, enabled, None) + } + + /// [`Self::new`] carrying the active profile id (1c). The session builder + /// passes the resolved profile so captured records are stamped with it. + pub fn with_profile( + memory: Arc, + enabled: bool, + profile_id: Option, + ) -> Self { Self { store: AgentExperienceStore::new(memory), enabled, + profile_id, } } pub fn from_store(store: AgentExperienceStore, enabled: bool) -> Self { - Self { store, enabled } + Self { + store, + enabled, + profile_id: None, + } } pub fn extract_candidates(ctx: &TurnContext) -> Vec { @@ -64,7 +84,23 @@ impl PostTurnHook for AgentExperienceCaptureHook { return Ok(()); } - for candidate in Self::extract_candidates(ctx) { + for mut candidate in Self::extract_candidates(ctx) { + // Stamp the active profile (1c) so retrieval can partition. Left as + // `None` for the profile-less session — unstamped records read as + // shared/legacy and surface under any profile. + candidate.profile_id = self.profile_id.clone(); + // Re-derive the storage key to include the profile now that it's + // stamped: `extract_candidates` builds the id profile-agnostically, so + // two profiles hitting the same task/tool/outcome triple would + // otherwise share one `experience/` key and overwrite each other. + // `None` reproduces the legacy id byte-for-byte (see + // `stable_experience_id_for_profile`). + candidate.id = stable_experience_id_for_profile( + &candidate.task_summary, + &candidate.tool_sequence, + candidate.outcome, + candidate.profile_id.as_deref(), + ); if let Err(err) = self.store.put(candidate).await { log::warn!("[agent-experience] failed to capture turn experience: {err}"); } @@ -217,6 +253,10 @@ fn build_experience( source: ExperienceSource::ToolLoop, agent_id: clean_optional(agent_id), entrypoint: clean_optional(entrypoint), + // Stamped by the hook's `on_turn_complete` from the active profile; + // `extract_candidates` stays profile-agnostic so its unit tests need no + // profile context. + profile_id: None, task_fingerprint: stable_task_fingerprint(&task_summary), task_summary, tools_used, @@ -370,5 +410,78 @@ mod tests { assert_eq!(stored[0].outcome, ExperienceOutcome::Success); assert_eq!(stored[0].agent_id.as_deref(), Some("orchestrator")); assert_eq!(stored[0].entrypoint.as_deref(), Some("web_channel")); + // Profile-less hook leaves records unstamped (shared/legacy). + assert_eq!(stored[0].profile_id, None); + } + + #[tokio::test] + async fn on_turn_complete_stamps_active_profile() { + let memory: Arc = Arc::new(MockMemory::default()); + let hook = AgentExperienceCaptureHook::with_profile( + memory.clone(), + true, + Some("alice".to_string()), + ); + + hook.on_turn_complete(&ctx_with(vec![ + call("grep", true, "grep: ok (20 chars)"), + call("file_read", true, "file_read: ok (100 chars)"), + ])) + .await + .unwrap(); + + let stored = AgentExperienceStore::new(memory).list().await.unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].profile_id.as_deref(), + Some("alice"), + "captured record must be stamped with the active profile id" + ); + } + + #[tokio::test] + async fn identical_candidates_under_different_profiles_do_not_collide() { + // Alice and Bob learn the same task/tool/outcome triple. Their records + // must land under distinct storage keys so neither overwrites the other, + // and the profile-less (None) key must match the legacy derivation. + let calls = || { + vec![ + call("grep", true, "grep: ok (20 chars)"), + call("file_read", true, "file_read: ok (100 chars)"), + ] + }; + + let memory: Arc = Arc::new(MockMemory::default()); + AgentExperienceCaptureHook::with_profile(memory.clone(), true, Some("alice".to_string())) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + AgentExperienceCaptureHook::with_profile(memory.clone(), true, Some("bob".to_string())) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + AgentExperienceCaptureHook::new(memory.clone(), true) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + + let stored = AgentExperienceStore::new(memory).list().await.unwrap(); + // Three distinct records rather than one repeatedly-overwritten key. + assert_eq!(stored.len(), 3, "each profile keeps its own record"); + let ids: std::collections::HashSet<&str> = stored.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids.len(), 3, "the three storage keys must be distinct"); + + // The profile-less record's key matches the legacy (profile-agnostic) + // derivation for the same triple. + let none_record = stored + .iter() + .find(|e| e.profile_id.is_none()) + .expect("a profile-less record"); + let legacy_id = stable_experience_id( + &none_record.task_summary, + &none_record.tool_sequence, + none_record.outcome, + ); + assert_eq!(none_record.id, legacy_id); } } diff --git a/src/openhuman/agent_experience/mod.rs b/src/openhuman/agent_experience/mod.rs index 032e74c38a..e73be02bfb 100644 --- a/src/openhuman/agent_experience/mod.rs +++ b/src/openhuman/agent_experience/mod.rs @@ -13,7 +13,9 @@ pub use schemas::{ all_controller_schemas as all_agent_experience_controller_schemas, all_registered_controllers as all_agent_experience_registered_controllers, }; -pub use store::{AgentExperienceStore, ExperienceQuery, AGENT_EXPERIENCE_NAMESPACE}; +pub use store::{ + retrieve_across_stores, AgentExperienceStore, ExperienceQuery, AGENT_EXPERIENCE_NAMESPACE, +}; pub use types::{ redact_text, stable_experience_id, AgentExperience, ExperienceHit, ExperienceOutcome, ExperienceSource, diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index abfde6d143..250a441831 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -1,9 +1,13 @@ use serde::{Deserialize, Serialize}; -use crate::openhuman::agent_experience::store::{AgentExperienceStore, ExperienceQuery}; +use crate::openhuman::agent_experience::store::{ + retrieve_across_stores, AgentExperienceStore, ExperienceQuery, +}; use crate::openhuman::agent_experience::types::{AgentExperience, ExperienceHit}; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct CaptureParams { @@ -21,13 +25,26 @@ pub struct RetrieveParams { pub agent_id: Option, #[serde(default)] pub entrypoint: Option, + /// Profile partition filter (1c). `None` (omitted) recalls the whole pool; + /// `Some(P)` recalls records stamped `P` plus unstamped legacy records. + #[serde(default)] + pub profile_id: Option, #[serde(default)] pub max_hits: Option, } +#[derive(Debug, Deserialize, Default)] +pub struct ListParams { + /// Profile partition filter (1c), same semantics as `RetrieveParams`. + #[serde(default)] + pub profile_id: Option, +} + #[derive(Debug, Deserialize)] pub struct DismissParams { pub id: String, + #[serde(default)] + pub profile_id: Option, } #[derive(Debug, Serialize)] @@ -36,43 +53,152 @@ pub struct DismissResult { pub dismissed: bool, } -async fn open_store() -> Result { +fn profile_memory_subdir( + workspace_dir: &std::path::Path, + profile_id: Option<&str>, +) -> Result { + let Some(profile_id) = profile_id.map(str::trim).filter(|id| !id.is_empty()) else { + return Ok("memory".to_string()); + }; + let state = crate::openhuman::profiles::load_profiles(workspace_dir)?; + let profile = state + .profiles + .iter() + .find(|profile| profile.id == profile_id) + .ok_or_else(|| format!("agent profile '{profile_id}' not found"))?; + let suffix = crate::openhuman::profiles::effective_memory_suffix(profile); + Ok(crate::openhuman::profiles::memory_subdir_for_suffix( + &suffix, + )) +} + +async fn open_store(profile_id: Option<&str>) -> Result { + let profile_id = profile_id.map(str::trim).filter(|id| !id.is_empty()); + if profile_id.is_none() { + let client = match crate::openhuman::memory::global::client_if_ready() { + Some(client) => client, + None => { + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + crate::openhuman::memory::global::init(config.workspace_dir)? + } + }; + return Ok(AgentExperienceStore::new(client.memory_handle())); + } + + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + let memory_subdir = profile_memory_subdir(&config.workspace_dir, profile_id)?; + + open_store_in_subdir(&config, &memory_subdir).await +} + +async fn open_store_in_subdir( + config: &Config, + memory_subdir: &str, +) -> Result { + if memory_subdir != "memory" { + let memory = crate::openhuman::memory_store::UnifiedMemory::new_with_memory_dir( + &config.workspace_dir, + memory_subdir, + crate::openhuman::embeddings::default_embedding_provider(), + config.memory.sqlite_open_timeout_secs, + ) + .map_err(|e| format!("open agent experience store '{memory_subdir}': {e:#}"))?; + return Ok(AgentExperienceStore::new(Arc::new(memory))); + } + let client = match crate::openhuman::memory::global::client_if_ready() { Some(client) => client, - None => { - let config = Config::load_or_init() - .await - .map_err(|e| format!("load config: {e}"))?; - crate::openhuman::memory::global::init(config.workspace_dir)? - } + None => crate::openhuman::memory::global::init(config.workspace_dir.clone())?, }; Ok(AgentExperienceStore::new(client.memory_handle())) } +fn query_memory_subdirs( + workspace_dir: &std::path::Path, + profile_id: Option<&str>, +) -> Result, String> { + let state = crate::openhuman::profiles::load_profiles(workspace_dir)?; + let mut subdirs = BTreeSet::from(["memory".to_string()]); + let profile_id = profile_id.map(str::trim).filter(|id| !id.is_empty()); + + for profile in &state.profiles { + if profile_id.is_none_or(|id| profile.id == id) { + let suffix = crate::openhuman::profiles::effective_memory_suffix(profile); + subdirs.insert(crate::openhuman::profiles::memory_subdir_for_suffix( + &suffix, + )); + } + } + if let Some(profile_id) = profile_id { + if !state + .profiles + .iter() + .any(|profile| profile.id == profile_id) + { + return Err(format!("agent profile '{profile_id}' not found")); + } + } + Ok(subdirs.into_iter().collect()) +} + +async fn open_query_stores(profile_id: Option<&str>) -> Result, String> { + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + let subdirs = query_memory_subdirs(&config.workspace_dir, profile_id)?; + let mut stores = Vec::with_capacity(subdirs.len()); + for subdir in subdirs { + stores.push(open_store_in_subdir(&config, &subdir).await?); + } + Ok(stores) +} + pub async fn capture(params: CaptureParams) -> Result, String> { - let store = open_store().await?; + let store = open_store(params.experience.profile_id.as_deref()).await?; let stored = store.put(params.experience).await?; Ok(RpcOutcome::single_log(stored, "agent experience captured")) } pub async fn retrieve(params: RetrieveParams) -> Result>, String> { - let store = open_store().await?; - let hits = store - .retrieve(ExperienceQuery { - query: params.query, - tools: params.tools, - tags: params.tags, - agent_id: params.agent_id, - entrypoint: params.entrypoint, - max_hits: params.max_hits.unwrap_or(5), - }) - .await?; + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let max_hits = params.max_hits.unwrap_or(5); + let query = ExperienceQuery { + query: params.query, + tools: params.tools, + tags: params.tags, + agent_id: params.agent_id, + entrypoint: params.entrypoint, + profile_id: params.profile_id, + max_hits, + }; + let hits = retrieve_across_stores(&stores, query).await?; Ok(RpcOutcome::single_log(hits, "agent experiences retrieved")) } -pub async fn list() -> Result>, String> { - let store = open_store().await?; - let experiences = store.list().await?; +pub async fn list(params: ListParams) -> Result>, String> { + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let mut by_id: BTreeMap = BTreeMap::new(); + for store in stores { + for experience in store.list_for_profile(params.profile_id.as_deref()).await? { + let id = experience.id.clone(); + match by_id.get(&id) { + Some(existing) if existing.updated_at_ms >= experience.updated_at_ms => {} + _ => { + by_id.insert(id, experience); + } + } + } + } + let mut experiences: Vec<_> = by_id.into_values().collect(); + experiences.sort_by(|a, b| { + b.updated_at_ms + .cmp(&a.updated_at_ms) + .then_with(|| a.id.cmp(&b.id)) + }); Ok(RpcOutcome::single_log( experiences, "agent experiences listed", @@ -80,8 +206,13 @@ pub async fn list() -> Result>, String> { } pub async fn dismiss(params: DismissParams) -> Result, String> { - let store = open_store().await?; - let dismissed = store.dismiss(¶ms.id).await?; + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let mut dismissed = false; + for store in stores { + dismissed |= store + .dismiss_for_profile(¶ms.id, params.profile_id.as_deref()) + .await?; + } Ok(RpcOutcome::single_log( DismissResult { id: params.id, @@ -90,3 +221,41 @@ pub async fn dismiss(params: DismissParams) -> Result, "agent experience dismissed", )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_memory_subdir_matches_live_session_derivation() { + let workspace = tempfile::TempDir::new().unwrap(); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.name = "Alice".into(); + profile.built_in = false; + profile.is_master = false; + profile.dedicated_memory = true; + crate::openhuman::profiles::store::AgentProfileStore::new(workspace.path().to_path_buf()) + .upsert(profile) + .expect("seed profile"); + + assert_eq!( + profile_memory_subdir(workspace.path(), Some("alice")).unwrap(), + "memory-alice" + ); + assert_eq!( + profile_memory_subdir(workspace.path(), None).unwrap(), + "memory" + ); + assert!(profile_memory_subdir(workspace.path(), Some("missing")).is_err()); + + assert_eq!( + query_memory_subdirs(workspace.path(), Some("alice")).unwrap(), + vec!["memory".to_string(), "memory-alice".to_string()] + ); + assert_eq!( + query_memory_subdirs(workspace.path(), None).unwrap(), + vec!["memory".to_string(), "memory-alice".to_string()] + ); + } +} diff --git a/src/openhuman/agent_experience/prompt.rs b/src/openhuman/agent_experience/prompt.rs index 6357beeae0..73fbce82a1 100644 --- a/src/openhuman/agent_experience/prompt.rs +++ b/src/openhuman/agent_experience/prompt.rs @@ -123,6 +123,7 @@ mod tests { source: ExperienceSource::ToolLoop, agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, task_fingerprint: "fp".into(), task_summary: "search docs".into(), tools_used: vec!["grep".into(), "file_read".into()], diff --git a/src/openhuman/agent_experience/schemas.rs b/src/openhuman/agent_experience/schemas.rs index 9cce34ba6b..fa95107147 100644 --- a/src/openhuman/agent_experience/schemas.rs +++ b/src/openhuman/agent_experience/schemas.rs @@ -3,7 +3,9 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent_experience::ops::{CaptureParams, DismissParams, RetrieveParams}; +use crate::openhuman::agent_experience::ops::{ + CaptureParams, DismissParams, ListParams, RetrieveParams, +}; use crate::rpc::RpcOutcome; pub fn all_controller_schemas() -> Vec { @@ -91,6 +93,14 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Optional turn entrypoint or event channel.", required: false, }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: + "Optional profile partition: returns records stamped with this \ + profile plus unstamped legacy records; omit to recall the whole pool.", + required: false, + }, FieldSchema { name: "max_hits", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), @@ -109,7 +119,14 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "agent_experience", function: "list", description: "List locally stored procedural operating experiences.", - inputs: vec![], + inputs: vec![FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: + "Optional profile partition: lists records stamped with this profile plus \ + unstamped legacy records; omit to list the whole pool.", + required: false, + }], outputs: vec![FieldSchema { name: "experiences", ty: TypeSchema::Array(Box::new(TypeSchema::Ref("AgentExperience"))), @@ -121,12 +138,20 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "agent_experience", function: "dismiss", description: "Mark an operating experience as dismissed so retrieval ignores it.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Experience id to dismiss.", - required: true, - }], + inputs: vec![ + FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "Experience id to dismiss.", + required: true, + }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional owning profile whose memory store contains the experience.", + required: false, + }, + ], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Object { @@ -183,8 +208,11 @@ fn handle_retrieve(params: Map) -> ControllerFuture { }) } -fn handle_list(_params: Map) -> ControllerFuture { - Box::pin(async move { to_json(crate::openhuman::agent_experience::ops::list().await?) }) +fn handle_list(params: Map) -> ControllerFuture { + Box::pin(async move { + let params = read_params::(params)?; + to_json(crate::openhuman::agent_experience::ops::list(params).await?) + }) } fn handle_dismiss(params: Map) -> ControllerFuture { diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index e2acea34a6..45b443b4a4 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -1,10 +1,10 @@ use crate::openhuman::agent_experience::types::{ - redact_text, stable_experience_id, AgentExperience, ExperienceHit, + redact_text, stable_experience_id_for_profile, AgentExperience, ExperienceHit, }; use crate::openhuman::memory::{Memory, MemoryCategory}; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; pub const AGENT_EXPERIENCE_NAMESPACE: &str = "agent_experience"; @@ -16,9 +16,36 @@ pub struct ExperienceQuery { pub tags: Vec, pub agent_id: Option, pub entrypoint: Option, + /// Profile partition (1c). When `Some(P)`, retrieval returns records stamped + /// `P` plus unstamped legacy records and excludes records stamped with a + /// different profile. When `None` (the profile-less session), every record + /// is in scope — see [`experience_matches_profile`]. + pub profile_id: Option, pub max_hits: usize, } +/// Profile partition predicate shared by retrieval and RPC list (1c). +/// +/// - A **profile-less** query (`query_profile == None`) sees **everything**: +/// the default session historically owns the whole shared experience pool and +/// must keep recalling every record it and prior versions wrote, so narrowing +/// it would silently drop guidance the default agent still relies on. +/// - A **profiled** query (`Some(P)`) sees records stamped `P` plus unstamped +/// legacy/shared records (`record_profile == None`), and excludes records +/// stamped with a different profile `Q` — the isolation the feature adds. +pub fn experience_matches_profile( + record_profile: Option<&str>, + query_profile: Option<&str>, +) -> bool { + match query_profile { + None => true, + Some(active) => match record_profile { + None => true, + Some(owner) => owner == active, + }, + } +} + #[derive(Clone)] pub struct AgentExperienceStore { memory: Arc, @@ -31,10 +58,11 @@ impl AgentExperienceStore { pub async fn put(&self, mut experience: AgentExperience) -> Result { if experience.id.trim().is_empty() { - experience.id = stable_experience_id( + experience.id = stable_experience_id_for_profile( &experience.task_summary, &experience.tool_sequence, experience.outcome, + experience.profile_id.as_deref(), ); } if experience.task_summary.trim().is_empty() { @@ -100,11 +128,44 @@ impl AgentExperienceStore { Ok(experiences) } + /// [`Self::list`] narrowed to a profile partition (1c). `profile_id == None` + /// returns everything (the profile-less view); `Some(P)` returns records + /// stamped `P` plus unstamped legacy records. Shares + /// [`experience_matches_profile`] with retrieval so the two never diverge. + pub async fn list_for_profile( + &self, + profile_id: Option<&str>, + ) -> Result, String> { + Ok(self + .list() + .await? + .into_iter() + .filter(|experience| { + experience_matches_profile(experience.profile_id.as_deref(), profile_id) + }) + .collect()) + } + pub async fn dismiss(&self, id: &str) -> Result { + self.dismiss_for_profile(id, None).await + } + + /// Dismiss an experience only when it belongs to the caller's visible + /// profile partition. A profiled caller may dismiss its own or unstamped + /// legacy records, but never a sibling profile's record even if it knows + /// the storage id. + pub async fn dismiss_for_profile( + &self, + id: &str, + profile_id: Option<&str>, + ) -> Result { let key = storage_key(id); let Some(mut experience) = self.fetch(&key).await? else { return Ok(false); }; + if !experience_matches_profile(experience.profile_id.as_deref(), profile_id) { + return Ok(false); + } experience.dismissed = true; experience.updated_at_ms = now_ms(); self.put(experience).await?; @@ -125,6 +186,12 @@ impl AgentExperienceStore { .await? .into_iter() .filter(|experience| !experience.dismissed) + .filter(|experience| { + experience_matches_profile( + experience.profile_id.as_deref(), + query.profile_id.as_deref(), + ) + }) .filter_map(|experience| { let (score, match_reasons) = score_experience( &experience, @@ -168,6 +235,41 @@ impl AgentExperienceStore { } } +/// Retrieve one logical experience pool across multiple physical memory stores. +/// +/// Dedicated profiles write new experiences into their own memory subtree, but +/// still need to recall unstamped legacy experiences from the shared store. +/// Keep the merge, de-duplication, ordering, and final limit in one place so the +/// RPC and live-turn paths cannot drift. +pub async fn retrieve_across_stores( + stores: &[AgentExperienceStore], + query: ExperienceQuery, +) -> Result, String> { + let max_hits = query.max_hits; + let mut by_id: BTreeMap = BTreeMap::new(); + for store in stores { + for hit in store.retrieve(query.clone()).await? { + let id = hit.experience.id.clone(); + match by_id.get(&id) { + Some(existing) if existing.score >= hit.score => {} + _ => { + by_id.insert(id, hit); + } + } + } + } + let mut hits: Vec<_> = by_id.into_values().collect(); + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(Ordering::Equal) + .then_with(|| b.experience.updated_at_ms.cmp(&a.experience.updated_at_ms)) + .then_with(|| a.experience.id.cmp(&b.experience.id)) + }); + hits.truncate(max_hits); + Ok(hits) +} + fn storage_key(id: &str) -> String { format!("experience/{}", id.trim()) } @@ -289,6 +391,7 @@ mod tests { source: ExperienceSource::ToolLoop, agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, task_fingerprint: format!("fp-{id}"), task_summary: task_summary.to_string(), tools_used: tools.iter().map(|tool| (*tool).to_string()).collect(), @@ -340,6 +443,28 @@ mod tests { assert!(listed[0].dismissed); } + #[tokio::test] + async fn generated_ids_partition_identical_experiences_by_profile() { + let (store, _) = fresh_store(); + let mut alice = sample_experience("", "same task", vec!["grep"], vec!["docs"], 0.8); + alice.profile_id = Some("alice".into()); + let mut bob = alice.clone(); + bob.profile_id = Some("bob".into()); + + let alice = store.put(alice).await.unwrap(); + let bob = store.put(bob).await.unwrap(); + + assert_ne!(alice.id, bob.id); + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 2); + assert!(listed + .iter() + .any(|item| item.profile_id.as_deref() == Some("alice"))); + assert!(listed + .iter() + .any(|item| item.profile_id.as_deref() == Some("bob"))); + } + #[tokio::test] async fn retrieve_ranks_tool_and_query_matches() { let (store, _) = fresh_store(); @@ -371,6 +496,7 @@ mod tests { tags: vec!["docs".into()], agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, max_hits: 2, }) .await @@ -383,6 +509,137 @@ mod tests { assert!(hits[0].match_reasons.contains(&"query_overlap".into())); } + #[test] + fn experience_matches_profile_partition_rules() { + // Profile-less query sees everything. + assert!(experience_matches_profile(None, None)); + assert!(experience_matches_profile(Some("p"), None)); + // Profiled query: own + legacy in, sibling out. + assert!(experience_matches_profile(Some("p"), Some("p"))); + assert!(experience_matches_profile(None, Some("p"))); + assert!(!experience_matches_profile(Some("q"), Some("p"))); + } + + async fn seed_partitioned(store: &AgentExperienceStore) { + let mut own = sample_experience("exp_p", "task p", vec!["grep"], vec!["docs"], 0.8); + own.profile_id = Some("p".into()); + store.put(own).await.unwrap(); + + let mut sibling = sample_experience("exp_q", "task q", vec!["grep"], vec!["docs"], 0.8); + sibling.profile_id = Some("q".into()); + store.put(sibling).await.unwrap(); + + // Unstamped legacy record. + store + .put(sample_experience( + "exp_legacy", + "task legacy", + vec!["grep"], + vec!["docs"], + 0.8, + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn retrieve_partitions_by_profile() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + // Profile P: sees P + legacy, never Q. + let hits = store + .retrieve(ExperienceQuery { + query: "task".into(), + tools: vec!["grep".into()], + tags: vec!["docs".into()], + profile_id: Some("p".into()), + max_hits: 10, + ..Default::default() + }) + .await + .unwrap(); + let ids: BTreeSet<_> = hits.iter().map(|h| h.experience.id.clone()).collect(); + assert!(ids.contains("exp_p"), "profile P must see its own record"); + assert!( + ids.contains("exp_legacy"), + "profile P must see legacy records" + ); + assert!(!ids.contains("exp_q"), "profile P must not see sibling Q"); + + // Profile-less: sees everything. + let all = store + .retrieve(ExperienceQuery { + query: "task".into(), + tools: vec!["grep".into()], + tags: vec!["docs".into()], + profile_id: None, + max_hits: 10, + ..Default::default() + }) + .await + .unwrap(); + let all_ids: BTreeSet<_> = all.iter().map(|h| h.experience.id.clone()).collect(); + assert!(all_ids.contains("exp_p")); + assert!(all_ids.contains("exp_q")); + assert!(all_ids.contains("exp_legacy")); + } + + #[tokio::test] + async fn dismiss_for_profile_rejects_sibling_record() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + assert!(!store.dismiss_for_profile("exp_q", Some("p")).await.unwrap()); + assert!(store + .list() + .await + .unwrap() + .iter() + .find(|experience| experience.id == "exp_q") + .is_some_and(|experience| !experience.dismissed)); + + assert!(store + .dismiss_for_profile("exp_legacy", Some("p")) + .await + .unwrap()); + assert!(store.dismiss_for_profile("exp_p", Some("p")).await.unwrap()); + } + + #[tokio::test] + async fn list_for_profile_partitions() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + let p_ids: BTreeSet<_> = store + .list_for_profile(Some("p")) + .await + .unwrap() + .into_iter() + .map(|e| e.id) + .collect(); + assert_eq!( + p_ids, + BTreeSet::from(["exp_legacy".to_string(), "exp_p".to_string()]) + ); + + let all_ids: BTreeSet<_> = store + .list_for_profile(None) + .await + .unwrap() + .into_iter() + .map(|e| e.id) + .collect(); + assert_eq!( + all_ids, + BTreeSet::from([ + "exp_legacy".to_string(), + "exp_p".to_string(), + "exp_q".to_string() + ]) + ); + } + #[tokio::test] async fn retrieve_ignores_dismissed_records() { let (store, _) = fresh_store(); @@ -405,6 +662,7 @@ mod tests { tags: vec!["docs".into()], agent_id: None, entrypoint: None, + profile_id: None, max_hits: 5, }) .await diff --git a/src/openhuman/agent_experience/types.rs b/src/openhuman/agent_experience/types.rs index 9d4bd7e061..8de027de0f 100644 --- a/src/openhuman/agent_experience/types.rs +++ b/src/openhuman/agent_experience/types.rs @@ -28,6 +28,13 @@ pub struct AgentExperience { pub source: ExperienceSource, pub agent_id: Option, pub entrypoint: Option, + /// Id of the agent profile the turn ran under when this experience was + /// captured (1c). `None` for the default profile-less session and for every + /// record written before profile scoping existed (legacy). Serde-defaulted + /// so older stored payloads deserialize unchanged; retrieval treats `None` + /// records as shared/legacy and surfaces them under any profile. + #[serde(default)] + pub profile_id: Option, pub task_fingerprint: String, pub task_summary: String, pub tools_used: Vec, @@ -67,6 +74,29 @@ pub fn stable_experience_id( task_summary: &str, tool_sequence: &[String], outcome: ExperienceOutcome, +) -> String { + stable_experience_id_for_profile(task_summary, tool_sequence, outcome, None) +} + +/// Derive a stable experience id, partitioning the storage key by the capturing +/// profile when one is set. +/// +/// The store keys records by this id, so two profiles that learn the *same* +/// task/tool/outcome triple must not collapse onto one key (the later +/// `store.put()` would otherwise overwrite the earlier profile's record — see +/// the 1c retrieval partition). Mixing the profile id into the digest keeps each +/// profile's procedural experience distinct. +/// +/// `profile_id == None` (the profile-less / legacy session) is **byte-identical** +/// to the pre-1c derivation, so every record written before profile scoping keeps +/// its exact id and stays retrievable. A `Some` profile appends a +/// domain-separated segment, so profile A, profile B, and `None` yield three +/// different keys for the same triple. +pub fn stable_experience_id_for_profile( + task_summary: &str, + tool_sequence: &[String], + outcome: ExperienceOutcome, + profile_id: Option<&str>, ) -> String { let mut hasher = Sha256::new(); hasher.update(task_summary.trim().to_lowercase().as_bytes()); @@ -76,6 +106,13 @@ pub fn stable_experience_id( hasher.update(b"\0"); } hasher.update(outcome_key(outcome).as_bytes()); + // Only stamp the profile segment when a non-empty id is present: an absent or + // blank profile must reproduce the legacy digest byte-for-byte so existing + // stored records keep their identity. + if let Some(profile_id) = profile_id.map(str::trim).filter(|id| !id.is_empty()) { + hasher.update(b"\0profile\0"); + hasher.update(profile_id.to_lowercase().as_bytes()); + } let digest = format!("{:x}", hasher.finalize()); format!("exp_{}", &digest[..24]) } @@ -148,4 +185,63 @@ mod tests { let failure = stable_experience_id("same task", &sequence, ExperienceOutcome::Failure); assert_ne!(success, failure); } + + #[test] + fn stable_experience_id_for_profile_none_matches_legacy_derivation() { + // `None` must be byte-identical to the pre-1c derivation so existing + // stored records keep their identity. + let sequence = vec!["grep".to_string(), "file_read".to_string()]; + let legacy = stable_experience_id("same task", &sequence, ExperienceOutcome::Success); + let none = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + None, + ); + assert_eq!(legacy, none); + // An empty / whitespace-only profile id is treated as `None`. + let blank = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some(" "), + ); + assert_eq!(legacy, blank); + } + + #[test] + fn stable_experience_id_for_profile_partitions_by_profile() { + // Same task/tool/outcome triple under profile A vs B vs None yields three + // distinct keys, so no profile can overwrite another's record. + let sequence = vec!["grep".to_string(), "file_read".to_string()]; + let none = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + None, + ); + let alice = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("alice"), + ); + let bob = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("bob"), + ); + assert_ne!(none, alice); + assert_ne!(none, bob); + assert_ne!(alice, bob); + // Deterministic per profile. + let alice_again = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("alice"), + ); + assert_eq!(alice, alice_again); + } } diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 7e98177ab2..c803676dc4 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -345,6 +345,9 @@ pub async fn start_channels(mut config: Config) -> Result<()> { &config, None, None, + None, + None, + None, )); let skills = crate::openhuman::skills::load_workflow_metadata(&workspace); diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 623bbf09c6..a6ab512241 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -911,7 +911,7 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< "[cron] building isolated agent for scheduled job" ); match build_agent_for_cron_job(&effective, job) { - Ok(mut agent) => { + Ok(BuiltCronAgent { mut agent, profile }) => { // Tag events so downstream subscribers can correlate // cron-triggered turns. `cron` is the channel so the // event bus can filter from other flows (`cli`, `web`…). @@ -927,9 +927,12 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< source: crate::openhuman::agent::turn_origin::TrustedAutomationSource::Cron, }; - let turn = crate::openhuman::agent::turn_origin::with_origin( - origin, - agent.run_single(&prefixed_prompt), + let turn = crate::openhuman::memory::source_scope::with_source_scope( + profile.and_then(|profile| profile.memory_sources), + crate::openhuman::agent::turn_origin::with_origin( + origin, + agent.run_single(&prefixed_prompt), + ), ); // Morning briefing only: install a 24h task-recency window // so Composio task-fetch tools (Linear/ClickUp/Notion/Asana) @@ -1014,7 +1017,112 @@ fn run_flow_schedule_job(job: &CronJob) -> (bool, String) { /// no text. Never delivered to chat — used only for the run-history record. const EMPTY_AGENT_OUTPUT: &str = "agent job executed"; -fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { +/// Resolve the agent profile a cron job is attributed to, if any. +/// +/// Returns `Some(profile)` only when `job.profile_id` is set AND that profile +/// still exists in the store. A deleted profile yields `Ok(None)` so the caller +/// runs the job without a profile rather than failing it (2b). Profile-store +/// failures are returned: attribution must not fail open when the scheduler +/// cannot determine whether the referenced profile still exists. +fn resolve_cron_profile( + config: &Config, + job: &CronJob, +) -> anyhow::Result> { + let Some(profile_id) = job.profile_id.as_deref() else { + return Ok(None); + }; + match crate::openhuman::profiles::load_profiles(&config.workspace_dir) { + Ok(state) => { + let found = state.profiles.into_iter().find(|p| p.id == profile_id); + if found.is_none() { + tracing::warn!( + job_id = %job.id, + profile_id = %profile_id, + "[cron] attributed profile no longer exists — running job without a profile" + ); + } + Ok(found) + } + Err(e) => Err(anyhow::anyhow!( + "failed to load attributed profile {profile_id:?} for cron job {}: {e}", + job.id + )), + } +} + +struct BuiltCronAgent { + agent: Agent, + profile: Option, +} + +fn apply_cron_profile_runtime_defaults( + config: &Config, + job: &CronJob, + profile: &crate::openhuman::profiles::AgentProfile, +) -> Config { + let mut effective = config.clone(); + if let Some(model) = profile.model_override.clone() { + effective.default_model = Some(model); + } + if let Some(temperature) = profile.temperature { + effective.default_temperature = temperature; + } + // A job-level pin is the most specific model choice. + if let Some(model) = job.model.clone() { + effective.default_model = Some(model); + } + effective +} + +fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { + // 2b — profile attribution. When the job names a profile that still exists, + // build the run under it via the SAME profile-aware session path the task + // dispatcher uses (`from_config_for_agent_with_profile`), so the run inherits + // the profile's SOUL, memory scope, dedicated-workspace descriptor, and + // tool/skill/MCP allowlists. A deleted profile falls through (warned in + // `resolve_cron_profile`) to the profile-less path below. + if let Some(profile) = resolve_cron_profile(config, job)? { + // Apply the same profile runtime defaults as interactive chat. A + // per-job model pin remains the most specific choice and therefore + // wins over the profile model. The profile-aware builder consumes the + // prompt suffix directly and gives profile temperature precedence over + // the selected agent definition. + let effective = apply_cron_profile_runtime_defaults(config, job, &profile); + // A job may pin a built-in `agent_id`; otherwise the profile picks its + // own agent definition. + let agent_id = job + .agent_id + .clone() + .unwrap_or_else(|| profile.agent_id.clone()); + let agent = Agent::from_config_for_agent_with_profile( + &effective, + &agent_id, + None, + profile.system_prompt_suffix.clone(), + Some(&profile), + ) + .inspect(|_| { + tracing::debug!( + job_id = %job.id, + profile_id = %profile.id, + agent_id = %agent_id, + "[cron] built scheduled job agent under attributed profile" + ); + }) + .map_err(|e| { + anyhow::anyhow!( + "failed to build cron job {} under attributed profile {:?} with agent {:?}: {e:#}", + job.id, + profile.id, + agent_id + ) + })?; + return Ok(BuiltCronAgent { + agent, + profile: Some(profile), + }); + } + if let Some(agent_id) = job.agent_id.as_deref() { match Agent::from_config_for_agent(config, agent_id) { Ok(agent) => { @@ -1023,7 +1131,10 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { tracing::warn!( @@ -1032,11 +1143,17 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result CronJob { session_target: SessionTarget::Isolated, model: None, agent_id: None, + profile_id: None, enabled: true, delivery: DeliveryConfig::default(), delete_after_run: false, @@ -50,6 +51,150 @@ fn test_job(command: &str) -> CronJob { } } +#[tokio::test] +async fn resolve_cron_profile_present_and_deleted_fallback() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + // A job attributed to profile "alice". + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice".into()); + + // Profile does not exist yet → None (the deleted-profile fallback path; + // the scheduler runs the job without a profile rather than failing it). + assert!( + resolve_cron_profile(&config, &job).unwrap().is_none(), + "missing profile must resolve to None" + ); + + // Seed the profile → it now resolves. + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.name = "Alice".into(); + profile.built_in = false; + profile.is_master = false; + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + let resolved = resolve_cron_profile(&config, &job) + .expect("profile store loads") + .expect("profile resolves"); + assert_eq!(resolved.id, "alice"); + + // A job with no attribution is always None. + let plain = test_job(""); + assert!(resolve_cron_profile(&config, &plain).unwrap().is_none()); +} + +#[tokio::test] +async fn existing_profile_agent_build_failure_does_not_fall_back_profile_less() { + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins() + .expect("init built-in agent definitions"); + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.agent_id = "removed-agent-definition".into(); + profile.built_in = false; + profile.is_master = false; + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice".into()); + + let error = match build_agent_for_cron_job(&config, &job) { + Ok(_) => panic!("existing profile build failure must not fall back profile-less"), + Err(error) => error, + }; + assert!(error.to_string().contains("under attributed profile")); + assert!(error.to_string().contains("removed-agent-definition")); +} + +#[tokio::test] +async fn attributed_cron_build_retains_profile_gates() { + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins() + .expect("init built-in agent definitions"); + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.built_in = false; + profile.allowed_tools = Some(vec!["file_read".into()]); + profile.memory_sources = Some(vec!["slack:#eng".into()]); + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice".into()); + let built = build_agent_for_cron_job(&config, &job).expect("build attributed cron agent"); + + assert_eq!( + built.agent.visible_tool_names_for_test(), + &["file_read".to_string()].into_iter().collect() + ); + assert_eq!( + built.profile.and_then(|profile| profile.memory_sources), + Some(vec!["slack:#eng".to_string()]), + "the run wrapper must retain the resolved profile for memory scoping" + ); +} + +#[tokio::test] +async fn attributed_cron_build_applies_profile_runtime_defaults() { + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins() + .expect("init built-in agent definitions"); + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice-runtime".into(); + profile.built_in = false; + profile.model_override = Some("profile-runtime-model".into()); + profile.temperature = Some(0.17); + profile.system_prompt_suffix = Some("CRON_PROFILE_SUFFIX_SENTINEL".into()); + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice-runtime".into()); + let built = build_agent_for_cron_job(&config, &job).expect("build attributed cron agent"); + + assert_eq!(built.agent.model_name(), "profile-runtime-model"); + assert_eq!(built.agent.temperature(), 0.17); + let prompt = built + .agent + .build_system_prompt(crate::openhuman::agent::prompts::LearnedContextData::default()) + .expect("build cron system prompt"); + assert!(prompt.contains("CRON_PROFILE_SUFFIX_SENTINEL")); +} + +#[test] +fn cron_job_model_override_wins_over_profile_model() { + let config = Config { + default_model: Some("config-model".into()), + ..Config::default() + }; + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.model_override = Some("profile-model".into()); + profile.temperature = Some(0.23); + let mut job = test_job(""); + job.model = Some("job-model".into()); + + let effective = apply_cron_profile_runtime_defaults(&config, &job, &profile); + assert_eq!(effective.default_model.as_deref(), Some("job-model")); + assert_eq!(effective.default_temperature, 0.23); +} + #[test] fn agent_failure_copy_mentions_retry_reporting_and_discord() { assert!(AGENT_JOB_USER_FAILURE_MESSAGE.contains("Something went wrong. Please try again.")); @@ -856,8 +1001,8 @@ async fn cron_agent_job_uses_agent_definition_tool_scope() { job.name = Some("morning_briefing".into()); job.agent_id = Some("morning_briefing".into()); - let agent = build_agent_for_cron_job(&config, &job).expect("build cron agent"); - let visible = agent.visible_tool_names_for_test(); + let built = build_agent_for_cron_job(&config, &job).expect("build cron agent"); + let visible = built.agent.visible_tool_names_for_test(); assert!( !visible.is_empty(), diff --git a/src/openhuman/cron/schemas.rs b/src/openhuman/cron/schemas.rs index c4d7a981cf..2a75269020 100644 --- a/src/openhuman/cron/schemas.rs +++ b/src/openhuman/cron/schemas.rs @@ -115,6 +115,13 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Built-in agent or skill definition ID.", required: false, }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Agent profile id to run the job under (soul, memory scope, \ + workspace, allowlists). Ignored if the profile is deleted.", + required: false, + }, FieldSchema { name: "delivery", ty: TypeSchema::Option(Box::new(TypeSchema::Ref("DeliveryConfig"))), @@ -311,6 +318,18 @@ fn handle_add(params: Map) -> ControllerFuture { .get("agent_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + // 2b — optional agent-profile attribution. Snake_case `profile_id` matches + // the existing cron wire convention (`agent_id`, `session_target`). + let profile_id = params + .get("profile_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + // Privacy-safe diagnostic: whether the created job carries profile + // attribution, never the profile id itself. + tracing::debug!( + has_profile_attribution = profile_id.is_some(), + "[cron][schemas] create: parsed agent-profile attribution" + ); let delivery: Option = match params.get("delivery") { None | Some(Value::Null) => None, @@ -358,6 +377,7 @@ fn handle_add(params: Map) -> ControllerFuture { agent_id, // RPC-created jobs default to enabled (current behaviour). true, + profile_id, ) .map_err(|e| e.to_string())? } @@ -380,6 +400,19 @@ fn handle_update(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let job_id = read_required::(¶ms, "job_id")?; let patch = read_required::(¶ms, "patch")?; + // Privacy-safe diagnostic for the profile-attribution patch. Double-option + // `profile_id`: `None` = no change, `Some(None)` = clear, `Some(Some)` = + // (re)attribute. Log only the state, never the profile id. + let (patches_profile_attribution, clears_profile_attribution) = match &patch.profile_id { + None => (false, false), + Some(None) => (true, true), + Some(Some(_)) => (true, false), + }; + tracing::debug!( + patches_profile_attribution, + clears_profile_attribution, + "[cron][schemas] update: parsed agent-profile attribution patch" + ); to_json(crate::openhuman::cron::rpc::cron_update(&config, job_id.trim(), patch).await?) }) } diff --git a/src/openhuman/cron/seed.rs b/src/openhuman/cron/seed.rs index e80f6fc4d5..dfd8f75c95 100644 --- a/src/openhuman/cron/seed.rs +++ b/src/openhuman/cron/seed.rs @@ -195,6 +195,7 @@ fn seed_morning_briefing(config: &Config) -> Result<()> { false, // recurring — do not delete after run Some(MORNING_BRIEFING_JOB_NAME.to_string()), false, // enabled=false — opt-in, created disabled atomically + None, // no profile attribution for the seeded briefing )?; tracing::debug!( @@ -255,6 +256,7 @@ fn seed_tinyplace_autopilot(config: &Config) -> Result<()> { // Runs the single tiny.place agent autonomously (no dedicated agent def). Some("tinyplace_agent".to_string()), false, // enabled=false — opt-in, created disabled atomically + None, // no profile attribution for the seeded autopilot )?; tracing::debug!( @@ -488,6 +490,7 @@ mod tests { true, Some(LEGACY_WELCOME_JOB_NAME.to_string()), true, // enabled + None, // no profile attribution ) .expect("seed legacy welcome"); assert_eq!(list_jobs(&config).unwrap().len(), 1); diff --git a/src/openhuman/cron/store.rs b/src/openhuman/cron/store.rs index 7f4e4ae139..07c26dfe55 100644 --- a/src/openhuman/cron/store.rs +++ b/src/openhuman/cron/store.rs @@ -79,6 +79,7 @@ pub fn add_agent_job( delete_after_run, None, true, + None, ) } @@ -97,6 +98,7 @@ pub fn add_agent_job_with_definition( delete_after_run: bool, agent_id: Option, enabled: bool, + profile_id: Option, ) -> Result { let now = Utc::now(); validate_schedule(&schedule, now)?; @@ -114,8 +116,8 @@ pub fn add_agent_job_with_definition( conn.execute( "INSERT INTO cron_jobs ( id, expression, command, schedule, job_type, prompt, name, session_target, model, - enabled, delivery, delete_after_run, created_at, next_run, agent_id - ) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, ?13, ?8, ?9, ?10, ?11, ?12)", + enabled, delivery, delete_after_run, created_at, next_run, agent_id, profile_id + ) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, ?13, ?8, ?9, ?10, ?11, ?12, ?14)", params![ id, expression, @@ -130,6 +132,7 @@ pub fn add_agent_job_with_definition( next_run.to_rfc3339(), agent_id, if enabled { 1 } else { 0 }, + profile_id, ], ) .context("Failed to insert cron agent job")?; @@ -219,7 +222,7 @@ pub fn find_flow_schedule_job(config: &Config, flow_id: &str) -> Result Result> { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs ORDER BY next_run ASC", )?; @@ -254,7 +257,7 @@ pub fn get_job(config: &Config, job_id: &str) -> Result { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs WHERE id = ?1", )?; @@ -376,7 +379,7 @@ pub fn due_jobs(config: &Config, now: DateTime) -> Result> { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs WHERE enabled = 1 AND next_run <= ?1 ORDER BY next_run ASC @@ -431,6 +434,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< if let Some(agent_id) = patch.agent_id { job.agent_id = agent_id; } + if let Some(profile_id) = patch.profile_id { + job.profile_id = profile_id; + } if schedule_changed { job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?; @@ -459,7 +465,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< "UPDATE cron_jobs SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6, session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11, - next_run = ?12, agent_id = ?14 + next_run = ?12, agent_id = ?14, profile_id = ?15 WHERE id = ?13", params![ job.expression, @@ -476,6 +482,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< job.next_run.to_rfc3339(), job.id, job.agent_id, + job.profile_id, ], ) .context("Failed to update cron job")?; @@ -683,6 +690,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { session_target: SessionTarget::parse(&row.get::<_, String>(7)?), model: row.get(8)?, agent_id: row.get(17)?, + profile_id: row.get(18)?, enabled: row.get::<_, i64>(9)? != 0, delivery, delete_after_run: row.get::<_, i64>(11)? != 0, @@ -828,6 +836,7 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) add_column_if_missing(&conn, "delivery", "TEXT")?; add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?; add_column_if_missing(&conn, "agent_id", "TEXT")?; + add_column_if_missing(&conn, "profile_id", "TEXT")?; f(&conn) } diff --git a/src/openhuman/cron/store_tests.rs b/src/openhuman/cron/store_tests.rs index 87f67269a5..04f17f6ee1 100644 --- a/src/openhuman/cron/store_tests.rs +++ b/src/openhuman/cron/store_tests.rs @@ -103,6 +103,84 @@ fn due_jobs_filters_by_timestamp_and_enabled() { assert!(due_after_disable.is_empty()); } +#[test] +fn agent_job_round_trips_profile_id_and_patch_clears_it() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Create an agent job attributed to a profile. + let job = add_agent_job_with_definition( + &config, + Some("attributed".into()), + Schedule::Cron { + expr: "0 9 * * *".into(), + tz: None, + active_hours: None, + }, + "do the thing", + SessionTarget::Isolated, + None, + None, + false, + None, + true, + Some("alice".into()), + ) + .unwrap(); + assert_eq!(job.profile_id.as_deref(), Some("alice")); + + // Reload from disk — the column round-trips. + let stored = get_job(&config, &job.id).unwrap(); + assert_eq!(stored.profile_id.as_deref(), Some("alice")); + + // Patch to a different profile. + let repointed = update_job( + &config, + &job.id, + CronJobPatch { + profile_id: Some(Some("bob".into())), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(repointed.profile_id.as_deref(), Some("bob")); + + // Patch with `Some(None)` clears the attribution; `None` leaves it untouched. + let cleared = update_job( + &config, + &job.id, + CronJobPatch { + profile_id: Some(None), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(cleared.profile_id, None); + + let untouched = update_job( + &config, + &job.id, + CronJobPatch { + name: Some("renamed".into()), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(untouched.profile_id, None); + assert_eq!(untouched.name.as_deref(), Some("renamed")); +} + +#[test] +fn shell_job_has_no_profile_attribution() { + // Back-compat: shell jobs (and any job created without profile_id) load with + // profile_id = None. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap(); + assert_eq!(job.profile_id, None); + assert_eq!(get_job(&config, &job.id).unwrap().profile_id, None); +} + #[test] fn enabling_stale_disabled_job_refreshes_next_run() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/cron/types.rs b/src/openhuman/cron/types.rs index db7d0652a0..27bc06e0dc 100644 --- a/src/openhuman/cron/types.rs +++ b/src/openhuman/cron/types.rs @@ -232,6 +232,15 @@ pub struct CronJob { /// definition's prompt, tool allowlist, iteration cap, and model hint /// instead of the generic `Agent::from_config` path. pub agent_id: Option, + /// Optional agent-profile id (`profiles::AgentProfile::id`) this job runs + /// under. When set and the profile still exists, the triggered run is built + /// via the profile-aware session path so it inherits the profile's SOUL, + /// memory scope, workspace descriptor, and allowlists. When the profile was + /// deleted, the scheduler warns and runs without a profile (never fails the + /// job). `#[serde(default)]` keeps legacy rows / payloads without the field + /// deserializing unchanged. + #[serde(default)] + pub profile_id: Option, pub enabled: bool, pub delivery: DeliveryConfig, pub delete_after_run: bool, @@ -253,6 +262,30 @@ pub struct CronRun { pub duration_ms: Option, } +/// Deserialize a nullable patch field with true double-option semantics: +/// +/// | wire | result | meaning | +/// | --------------- | ------------- | ------------- | +/// | key absent | `None` | no change | +/// | key present `null` | `Some(None)` | clear the value | +/// | key present value | `Some(Some(v))` | set the value | +/// +/// A plain `#[derive(Deserialize)]` on `Option>` collapses the absent +/// and the `null` cases *both* to the outer `None`, so "clear over the wire" +/// (`{"profile_id": null}`) silently deserializes as "no change" — a no-op. Used +/// with `#[serde(default, deserialize_with = "deserialize_double_option")]`, +/// this helper restores the distinction: serde only invokes it when the key is +/// *present*, so a present `null` becomes `Some(None)` and a present value +/// becomes `Some(Some(v))`, while an absent key falls back to the `default` +/// (`None`). +fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Some(Option::::deserialize(deserializer)?)) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CronJobPatch { pub schedule: Option, @@ -264,7 +297,16 @@ pub struct CronJobPatch { pub model: Option, pub session_target: Option, pub delete_after_run: Option, + /// `Option>` distinguishes "no change" (`None`) from + /// "clear the agent definition" (`Some(None)`). See + /// [`deserialize_double_option`] for why the custom deserializer is required + /// to honor a wire `null` as a clear rather than a silent no-op. + #[serde(default, deserialize_with = "deserialize_double_option")] pub agent_id: Option>, + /// `Option>` distinguishes "no change" (`None`) from + /// "clear the profile" (`Some(None)`) — same shape as `agent_id`. + #[serde(default, deserialize_with = "deserialize_double_option")] + pub profile_id: Option>, } #[cfg(test)] @@ -507,6 +549,91 @@ mod tests { assert!(p.agent_id.is_none()); } + #[test] + fn cron_job_deserializes_without_profile_id() { + // A pre-2b serialized CronJob (no `profile_id` key) must still + // deserialize, with the field defaulting to None. + let raw = json!({ + "id": "j1", + "expression": "0 9 * * *", + "schedule": { "kind": "cron", "expr": "0 9 * * *" }, + "command": "", + "prompt": "hi", + "name": "briefing", + "job_type": "agent", + "session_target": "isolated", + "model": null, + "agent_id": null, + "enabled": true, + "delivery": {}, + "delete_after_run": false, + "created_at": "2027-01-15T12:00:00Z", + "next_run": "2027-01-16T09:00:00Z", + "last_run": null, + "last_status": null, + "last_output": null + }); + let job: CronJob = serde_json::from_value(raw).unwrap(); + assert_eq!(job.profile_id, None); + } + + #[test] + fn cron_job_patch_default_leaves_profile_id_none() { + assert!(CronJobPatch::default().profile_id.is_none()); + } + + #[test] + fn cron_job_patch_profile_id_supports_explicit_none_clearing() { + // Option>: None = no change, Some(None) = clear. + let clear = CronJobPatch { + profile_id: Some(None), + ..Default::default() + }; + assert!(clear.profile_id.is_some()); + assert!(clear.profile_id.as_ref().unwrap().is_none()); + + let set = CronJobPatch { + profile_id: Some(Some("alice".into())), + ..Default::default() + }; + assert_eq!(set.profile_id, Some(Some("alice".to_string()))); + } + + #[test] + fn patch_profile_id_wire_double_option_semantics() { + // The RPC path deserializes CronJobPatch from JSON params — pin the three + // wire cases so a `null` clears rather than silently no-ops. + // absent key → no change. + let absent: CronJobPatch = serde_json::from_value(json!({})).unwrap(); + assert_eq!(absent.profile_id, None, "absent key means no change"); + // present null → clear. + let cleared: CronJobPatch = serde_json::from_value(json!({ "profile_id": null })).unwrap(); + assert_eq!( + cleared.profile_id, + Some(None), + "wire null must clear the attribution" + ); + // present value → set. + let set: CronJobPatch = serde_json::from_value(json!({ "profile_id": "writer" })).unwrap(); + assert_eq!(set.profile_id, Some(Some("writer".to_string()))); + } + + #[test] + fn patch_agent_id_wire_double_option_semantics() { + // Same fix applied consistently to `agent_id` (its doc + the struct-level + // clearing test already document the Some(None)=clear intent). + let absent: CronJobPatch = serde_json::from_value(json!({})).unwrap(); + assert_eq!(absent.agent_id, None, "absent key means no change"); + let cleared: CronJobPatch = serde_json::from_value(json!({ "agent_id": null })).unwrap(); + assert_eq!( + cleared.agent_id, + Some(None), + "wire null must clear the agent definition" + ); + let set: CronJobPatch = serde_json::from_value(json!({ "agent_id": "welcome" })).unwrap(); + assert_eq!(set.agent_id, Some(Some("welcome".to_string()))); + } + #[test] fn cron_job_patch_agent_id_supports_explicit_none_clearing() { // Option> lets callers distinguish "no change" diff --git a/src/openhuman/memory/source_scope.rs b/src/openhuman/memory/source_scope.rs index 57dd1c62ea..06788a6637 100644 --- a/src/openhuman/memory/source_scope.rs +++ b/src/openhuman/memory/source_scope.rs @@ -10,8 +10,8 @@ //! Semantics: //! - `None` scope (outside any [`with_source_scope`], or `with_source_scope(None, …)`) //! means **unrestricted** — every source tree is visible. This is the default -//! for cron, sub-agents, the CLI, and any profile that left `memory_sources` -//! unset. +//! for profile-less cron, sub-agents, the CLI, and any profile that left +//! `memory_sources` unset. //! - `Some(set)` restricts recall to source trees whose `scope` string is in the //! set. An empty set surfaces nothing (the profile selected no sources). //! diff --git a/src/openhuman/memory_store/factories.rs b/src/openhuman/memory_store/factories.rs index c13a76bd3d..83b94fea77 100644 --- a/src/openhuman/memory_store/factories.rs +++ b/src/openhuman/memory_store/factories.rs @@ -356,6 +356,12 @@ pub(crate) fn create_session_memory_with_local_ai( embedding_routes: &[EmbeddingRouteConfig], storage_provider: Option<&StorageProviderConfig>, workspace_dir: &Path, + // Memory subdirectory under `workspace_dir` — `"memory"` for the shared + // tree, `"memory-"` for a profile that opted into dedicated memory + // (derived by the session builder from `effective_memory_suffix`). Routes + // the session's captures + recall (the `UnifiedMemory` SQLite store) into the + // profile's own subtree so `dedicatedMemory` isolation actually takes effect. + memory_subdir: &str, ) -> anyhow::Result { let memory = create_unified_memory_full( memory, @@ -364,6 +370,7 @@ pub(crate) fn create_session_memory_with_local_ai( local_embedding_model, embedding_api_key, workspace_dir, + memory_subdir, )?; let sqlite_connection = Arc::clone(&memory.conn); Ok(SessionMemory { @@ -427,6 +434,9 @@ fn create_memory_full( local_embedding_model, embedding_api_key, workspace_dir, + // Non-session callers (migration, standalone memory) always use the + // shared default subtree. + "memory", )?)) } @@ -437,6 +447,7 @@ fn create_unified_memory_full( local_embedding_model: Option<&str>, embedding_api_key: &str, workspace_dir: &Path, + memory_subdir: &str, ) -> anyhow::Result { // 1. Resolve the intended provider from config. let intended = effective_embedding_settings(config, local_embedding_model); @@ -506,8 +517,15 @@ fn create_unified_memory_full( })?, ); - // 4. Instantiate UnifiedMemory which handles SQLite and vector storage. - UnifiedMemory::new(workspace_dir, embedder, config.sqlite_open_timeout_secs) + // 4. Instantiate UnifiedMemory which handles SQLite and vector storage, + // rooted at the caller-selected subtree (`memory` shared, `memory-` + // for a dedicated-memory profile). + UnifiedMemory::new_with_memory_dir( + workspace_dir, + memory_subdir, + embedder, + config.sqlite_open_timeout_secs, + ) } /// Create a memory instance specifically for migration purposes. diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs new file mode 100644 index 0000000000..40244ae44b --- /dev/null +++ b/src/openhuman/profiles/guard.rs @@ -0,0 +1,646 @@ +//! Active-profile identity plumbing + the cross-profile write guard. +//! +//! Two concerns, both keyed off *which* profile a turn runs under: +//! +//! 1. **Identity plumbing (1a).** When a dedicated-workspace profile is active, +//! its id is carried to the tool layer inside the +//! [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor)'s +//! `policy_id` field as `openhuman.profile:`. [`workspace_policy_id`] and +//! [`profile_id_from_policy_id`] are the single encode/decode pair so the +//! session builder and the tool gates can never drift on the wire format. +//! +//! 2. **Cross-profile write guard (1b).** A hermes `file_safety` equivalent: +//! while a turn runs under a dedicated-workspace profile `P`, any tool +//! write/command whose resolved target lands in a *sibling* profile's +//! workspace `/profiles//` (Q != P) is blocked. See +//! [`classify_cross_profile_target`] (file tools) and +//! [`scan_command_for_cross_profile`] (shell / `node_exec` / `npm_exec`). The +//! guard only ever **tightens**: with no active profile the classifier is +//! never consulted and behaviour is byte-identical to today. + +use std::path::{Component, Path, PathBuf}; + +/// Wire prefix for the per-profile `WorkspaceDescriptor::policy_id`. The suffix +/// is the profile id. Kept private-behind-helpers so the encode/decode pair is +/// the only way this string is produced or parsed. +const PROFILE_POLICY_ID_PREFIX: &str = "openhuman.profile:"; +/// Marker returned when the protected target is the shared `profiles/` root +/// rather than one named sibling profile. +pub const PROFILES_ROOT_SENTINEL: &str = ""; + +/// Encode a profile id as the `WorkspaceDescriptor::policy_id` the session +/// builder stamps onto a dedicated-workspace descriptor (`openhuman.profile:`). +/// +/// Paired with [`profile_id_from_policy_id`]; the two are the sole owners of the +/// wire format so the encode and decode sites can never disagree. +pub fn workspace_policy_id(profile_id: &str) -> String { + format!("{PROFILE_POLICY_ID_PREFIX}{profile_id}") +} + +/// Decode the active profile id from a `WorkspaceDescriptor::policy_id`. +/// +/// Returns `Some(id)` only for the `openhuman.profile:` shape +/// [`workspace_policy_id`] produces (and only when `` is non-empty); every +/// other policy_id — the worktree-isolation ids, test ids, or an empty string — +/// yields `None`, so a non-profile session reads as "no active profile" and the +/// tool gates stay on their shared-path behaviour. +pub fn profile_id_from_policy_id(policy_id: &str) -> Option<&str> { + policy_id + .strip_prefix(PROFILE_POLICY_ID_PREFIX) + .filter(|id| !id.is_empty()) +} + +/// Outcome of classifying a resolved write/command target against the active +/// profile's isolation boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CrossProfileDecision { + /// The target is not inside a *sibling* profile's workspace — permitted (the + /// active profile's own dir, the shared `action_dir`, or anywhere outside + /// `/profiles/` entirely). + Allow, + /// The target lands inside `/profiles//` with + /// `other_id != active_profile`, or is the shared profiles root itself — + /// blocked. Root targets carry [`PROFILES_ROOT_SENTINEL`]. + Block { + /// The sibling profile whose workspace the target tried to reach. + other_id: String, + }, +} + +/// Classify a resolved target path against the active profile's cross-profile +/// isolation boundary (the hermes `classify_cross_profile_target` analogue). +/// +/// `action_dir` is the agent's **broad** action root; sibling profile +/// workspaces live under `/profiles//`. `active_profile` is the +/// id of the profile the turn runs under. `target` is the write/command target +/// — ideally already resolved to an absolute path, but relative inputs are +/// joined onto `action_dir` first so the classifier is robust to either. +/// +/// Returns [`CrossProfileDecision::Block`] iff the canonicalized `target` is +/// the shared `/profiles/` root or is inside +/// `/profiles//` for some `Q != active_profile`, otherwise +/// [`CrossProfileDecision::Allow`]. +/// +/// **Symlink safety.** The comparison is done on canonicalized paths: the +/// profiles root and the target's deepest *existing* ancestor are both resolved +/// through the filesystem, so a symlink inside profile `P` pointing at profile +/// `Q`'s dir still classifies as a cross-profile target. A not-yet-existing +/// target (a fresh write) canonicalizes its nearest existing ancestor and +/// re-appends the missing tail, matching the `validate_parent_path` strategy. +pub fn classify_cross_profile_target( + action_dir: &Path, + active_profile: &str, + target: &Path, +) -> CrossProfileDecision { + let profiles_root = action_dir.join("profiles"); + // Resolve the profiles root through the filesystem so a symlinked + // action_dir (macOS `/tmp` -> `/private/tmp`, sandbox bind-mounts) compares + // against the same canonical prefix the target resolves to. + let canon_profiles_root = canonicalize_best_effort(&profiles_root); + + let absolute_target = if target.is_absolute() { + target.to_path_buf() + } else { + action_dir.join(target) + }; + let canon_target = canonicalize_deepest_existing(&absolute_target); + + let Ok(relative) = canon_target.strip_prefix(&canon_profiles_root) else { + return CrossProfileDecision::Allow; + }; + if relative.as_os_str().is_empty() { + return CrossProfileDecision::Block { + other_id: PROFILES_ROOT_SENTINEL.to_string(), + }; + } + // First component under `profiles/` is the owning profile id. + let Some(Component::Normal(owner)) = relative.components().next() else { + return CrossProfileDecision::Allow; + }; + let owner = owner.to_string_lossy(); + if owner == active_profile { + CrossProfileDecision::Allow + } else { + CrossProfileDecision::Block { + other_id: owner.into_owned(), + } + } +} + +/// Best-effort scan of a process `command` for a token that targets a sibling +/// profile's workspace, given the command's working directory `cwd` (the active +/// profile's own dir). +/// +/// # Guarantee level (read before relying on this) +/// +/// This is **best-effort defense-in-depth for model-facing process tools, not a +/// hard boundary.** It is a static, pre-execution token scan — it cannot see +/// what the process will actually do at runtime. Known, deliberate gaps: +/// +/// - **Variable / command substitution.** `$HOME`, `${VAR}`, `$(cmd)`, and +/// backtick substitution resolve to paths only at runtime; a token like +/// `$SOME_VAR` is not path-shaped textually and is skipped. +/// - **Paths embedded inside interpreter code.** `python -c 'open("../bob/x","w")'` +/// or any inline script hides the path inside a program string. The tokenizer +/// below splits on quotes/parens/commas/`=` so the *simple* embedded cases are +/// still isolated and classified, but an arbitrary interpreter can construct a +/// path the scanner never sees. +/// +/// The **hard** cross-profile boundary for file mutations is +/// [`SecurityPolicy::validate_path`](crate::openhuman::security) at the file-tool +/// call site (every write funnels through it). Process commands do **not** funnel +/// through that gate, so this scan is their only in-Rust backstop — and +/// airtight process confinement (an OS sandbox: cwd_jail / Seatbelt / Landlock +/// restricting the process to its own subtree) is deliberate follow-up work, not +/// provided here. Do not treat a `None` result as proof a command cannot reach a +/// sibling profile. +/// +/// # What it does catch +/// +/// It splits the command into simple `;` / `&&` / `||` segments, tracks a +/// leading literal `cd ` across those segments, then splits each segment +/// on whitespace, redirect/pipe operators, and common +/// shell punctuation (quotes, parens, backtick, `,`, `=`, `{`, `}`, `&`), keeps +/// path-shaped tokens (those containing a path separator or a leading `~`), +/// plus bare operands naming an existing profile when the tracked cwd is the +/// profiles root, resolves each against `cwd`, and classifies it via +/// [`classify_cross_profile_target`]. This reliably catches the realistic +/// non-adversarial escape vectors: an absolute path into `profiles/`, a +/// `..//…` traversal, a `--flag=..//…` form, and simple quoted paths. +/// Returns the first sibling profile id it would write into, or `None` when no +/// scanned token lands in another profile. +pub fn scan_command_for_cross_profile( + command: &str, + cwd: &Path, + action_dir: &Path, + active_profile: &str, +) -> Option { + let mut effective_cwd = cwd.to_path_buf(); + for segment in split_command_segments(command) { + if let Some(other_id) = + scan_command_segment(segment, &effective_cwd, action_dir, active_profile) + { + return Some(other_id); + } + + let Some(next_cwd) = simple_cd_target(segment, &effective_cwd) else { + continue; + }; + if let CrossProfileDecision::Block { other_id } = + classify_cross_profile_target(action_dir, active_profile, &next_cwd) + { + return Some(other_id); + } + effective_cwd = next_cwd; + } + None +} + +/// Split at top-level shell sequencing operators while leaving separators +/// inside simple quotes alone. This is intentionally not a complete shell +/// parser; it only supplies enough ordering for literal `cd` tracking. +fn split_command_segments(command: &str) -> Vec<&str> { + let bytes = command.as_bytes(); + let mut segments = Vec::new(); + let mut start = 0; + let mut quote: Option = None; + let mut i = 0; + while i < bytes.len() { + let byte = bytes[i]; + if matches!(byte, b'\'' | b'"' | b'`') { + if quote == Some(byte) { + quote = None; + } else if quote.is_none() { + quote = Some(byte); + } + i += 1; + continue; + } + if quote.is_none() + && (byte == b';' + || (i + 1 < bytes.len() + && ((byte == b'&' && bytes[i + 1] == b'&') + || (byte == b'|' && bytes[i + 1] == b'|')))) + { + segments.push(&command[start..i]); + i += if byte == b';' { 1 } else { 2 }; + start = i; + continue; + } + i += 1; + } + segments.push(&command[start..]); + segments +} + +fn scan_command_segment( + command: &str, + cwd: &Path, + action_dir: &Path, + active_profile: &str, +) -> Option { + let profiles_root = canonicalize_best_effort(&action_dir.join("profiles")); + let cwd_is_action_root = canonicalize_best_effort(cwd) == canonicalize_best_effort(action_dir); + let cwd_is_profiles_root = canonicalize_best_effort(cwd) == profiles_root; + let mut token_index = 0usize; + // Split on shell punctuation as well as whitespace/redirects so a path + // embedded in a quoted string, a `flag=value`, a comma list, or a brace + // expansion is isolated into its own token. Splitting only ever produces + // substrings of the original command, so it cannot invent a path that + // resolves into a sibling — it can only surface one that was genuinely + // referenced (no new false positives, strictly better coverage). + for raw in command.split(|c: char| { + c.is_whitespace() + || matches!( + c, + '>' | '<' | '|' | ';' | ',' | '=' | '"' | '\'' | '(' | ')' | '`' | '{' | '}' | '&' + ) + }) { + // Residual wrapper chars the split didn't consume at the edges. + let token = raw.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')' | '`')); + if token.is_empty() { + continue; + } + let is_command_word = token_index == 0; + token_index += 1; + // Ordinarily only path-shaped tokens can reach another directory. Once + // a preceding `cd` has moved cwd to `/profiles`, however, a + // bare operand can name a sibling directly (`rm -rf bob`). Scan such an + // operand only when it resolves to an existing profile directory; this + // avoids treating ordinary arguments (`echo hi`) as profile ids. + let path_shaped = + token == ".." || token.contains('/') || token.contains('\\') || token.starts_with('~'); + let bare_profile_operand = cwd_is_profiles_root + && !is_command_word + && !token.starts_with('-') + && cwd.join(token).is_dir(); + // From the shared action root, the literal bare operand `profiles` + // names the protected collection root itself (`rm -rf profiles`, + // `mv profiles backup`). It has no slash, so classify it explicitly + // before spawning just as we do bare sibling ids from inside that root. + let bare_profiles_root_operand = + cwd_is_action_root && !is_command_word && token == "profiles"; + if !path_shaped && !bare_profile_operand && !bare_profiles_root_operand { + continue; + } + let expanded = crate::openhuman::config::expand_tilde(token); + let candidate = Path::new(&expanded); + let absolute = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + cwd.join(candidate) + }; + if let CrossProfileDecision::Block { other_id } = + classify_cross_profile_target(action_dir, active_profile, &absolute) + { + return Some(other_id); + } + } + None +} + +/// Resolve a literal leading `cd` for the next sequenced command. Dynamic +/// targets (`$VAR`, command substitution) remain in the documented best-effort +/// gap. Nonexistent/non-directory targets are ignored because the shell's `cd` +/// would fail and leave cwd unchanged. +fn simple_cd_target(segment: &str, cwd: &Path) -> Option { + let mut words = segment.split_whitespace(); + if words.next()? != "cd" { + return None; + } + let mut target = words.next()?; + if target == "--" { + target = words.next()?; + } + let target = target.trim_matches(|c| matches!(c, '"' | '\'')); + if target.is_empty() || target.contains('$') || target.contains('`') || target.contains("$(") { + return None; + } + let expanded = crate::openhuman::config::expand_tilde(target); + let path = Path::new(&expanded); + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + candidate + .is_dir() + .then(|| canonicalize_best_effort(&candidate)) +} + +/// Canonicalize `path`, falling back to the raw path when it does not exist. +fn canonicalize_best_effort(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Canonicalize `path` when it exists; otherwise canonicalize its deepest +/// existing ancestor and re-append the missing tail. Mirrors +/// `SecurityPolicy::validate_parent_path`'s symlink-safe resolution so a fresh +/// (not-yet-created) write target is still classified against the real +/// filesystem layout. +fn canonicalize_deepest_existing(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + // Walk up to the deepest existing ancestor, collecting the non-existent tail. + let mut existing = path; + let mut tail: Vec> = Vec::new(); + loop { + if existing.exists() { + break; + } + match (existing.parent(), existing.components().next_back()) { + (Some(parent), Some(comp)) => { + tail.push(comp); + existing = parent; + } + _ => break, + } + } + let mut resolved = canonicalize_best_effort(existing); + for component in tail.into_iter().rev() { + resolved.push(component); + } + resolved +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_policy_id_round_trips() { + let encoded = workspace_policy_id("alice"); + assert_eq!(encoded, "openhuman.profile:alice"); + assert_eq!(profile_id_from_policy_id(&encoded), Some("alice")); + } + + #[test] + fn profile_id_from_policy_id_rejects_non_profile_ids() { + // Worktree-isolation / test ids and empty strings are not profiles. + assert_eq!(profile_id_from_policy_id("test-worktree"), None); + assert_eq!(profile_id_from_policy_id(""), None); + assert_eq!(profile_id_from_policy_id("openhuman.profile:"), None); + assert_eq!( + profile_id_from_policy_id("openhuman.profile:bob"), + Some("bob") + ); + } + + // ── Cross-profile classifier (1b) ───────────────────────────────────── + + fn profiles_layout() -> (tempfile::TempDir, PathBuf) { + let action = tempfile::tempdir().expect("action tempdir"); + let profiles = action.path().join("profiles"); + for id in ["alice", "bob"] { + std::fs::create_dir_all(profiles.join(id)).unwrap(); + } + let action_dir = action.path().to_path_buf(); + (action, action_dir) + } + + #[test] + fn same_profile_target_is_allowed() { + let (_g, action) = profiles_layout(); + let target = action.join("profiles").join("alice").join("notes.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Allow + ); + } + + #[test] + fn other_profile_target_is_blocked() { + let (_g, action) = profiles_layout(); + let target = action.join("profiles").join("bob").join("secret.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn target_outside_profiles_root_is_allowed() { + let (_g, action) = profiles_layout(); + // A plain file under action_dir (the shared workspace) — not under + // profiles/ at all. + let target = action.join("scratch.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Allow + ); + } + + #[test] + fn nonexistent_sibling_target_is_blocked_via_ancestor() { + let (_g, action) = profiles_layout(); + // File does not exist yet, but its parent (profiles/bob) does → the + // deepest-existing-ancestor resolution still classifies it as bob's. + let target = action + .join("profiles") + .join("bob") + .join("nested") + .join("fresh.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn relative_traversal_into_sibling_is_blocked() { + let (_g, action) = profiles_layout(); + // A relative `../bob/x` composed from the active profile's own dir. + let target = action + .join("profiles") + .join("alice") + .join("..") + .join("bob") + .join("x.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[cfg(unix)] + #[test] + fn symlink_into_sibling_profile_is_blocked() { + use std::os::unix::fs::symlink; + let (_g, action) = profiles_layout(); + // Inside alice, a symlink `link -> ../bob`. Writing `link/hijack.txt` + // must resolve to bob's dir and block. + let alice = action.join("profiles").join("alice"); + let bob = action.join("profiles").join("bob"); + symlink(&bob, alice.join("link")).unwrap(); + let target = alice.join("link").join("hijack.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn profiles_root_itself_is_blocked() { + // Mutating the shared root can affect every sibling at once. + let (_g, action) = profiles_layout(); + let target = action.join("profiles"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: PROFILES_ROOT_SENTINEL.into() + } + ); + } + + // ── Shell command scan (1b) ─────────────────────────────────────────── + + #[test] + fn scan_command_allows_same_profile_and_bare_tokens() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + // Relative writes under cwd, plain words, and reads under cwd are fine. + assert_eq!( + scan_command_for_cross_profile("echo hi > notes.txt", &cwd, &action, "alice"), + None + ); + assert_eq!( + scan_command_for_cross_profile("ls -la sub/dir", &cwd, &action, "alice"), + None + ); + } + + #[test] + fn scan_command_blocks_absolute_sibling_target() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + let sibling = action.join("profiles").join("bob").join("loot.txt"); + let command = format!("cat secret > {}", sibling.display()); + assert_eq!( + scan_command_for_cross_profile(&command, &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_blocks_relative_traversal_into_sibling() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cp x ../bob/y", &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_tracks_cd_before_sibling_write() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "cd .. && printf x > bob/loot.txt", + &cwd, + &action, + "alice" + ), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + + #[test] + fn scan_command_tracks_cd_before_bare_sibling_operand() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cd ..; rm -rf bob", &cwd, &action, "alice"), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + + #[test] + fn scan_command_blocks_parent_profiles_root_operand() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("rm -rf ..", &cwd, &action, "alice"), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + + #[test] + fn scan_command_blocks_bare_profiles_root_from_action_dir() { + let (_g, action) = profiles_layout(); + assert_eq!( + scan_command_for_cross_profile("rm -rf profiles", &action, &action, "alice"), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + + #[test] + fn scan_command_tracks_chained_bare_cd_into_sibling() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "cd ..; cd bob; printf x > loot.txt", + &cwd, + &action, + "alice" + ), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + + #[test] + fn scan_command_blocks_path_embedded_in_quoted_interpreter_arg() { + // The path is buried inside a python -c program string. Splitting on + // quotes/parens/commas isolates `../bob/loot.txt` so the simple embedded + // case is still caught. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + let command = r#"python -c 'open("../bob/loot.txt","w").write("x")'"#; + assert_eq!( + scan_command_for_cross_profile(command, &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_blocks_flag_equals_sibling_path() { + // A `--flag=../bob/…` form: splitting on `=` isolates the path token. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "tar --directory=../bob/x -cf a.tar .", + &cwd, + &action, + "alice" + ), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_documents_variable_expansion_gap() { + // Documented best-effort limitation: a shell variable that expands to a + // sibling path at runtime is not statically resolvable, so the scan does + // not catch it. The hard boundary for this is an OS sandbox (follow-up); + // this test pins the known gap so it's a conscious contract, not a + // surprise regression. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cp x $TARGET_DIR/y", &cwd, &action, "alice"), + None + ); + } +} diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs new file mode 100644 index 0000000000..1d256cad5f --- /dev/null +++ b/src/openhuman/profiles/home.rs @@ -0,0 +1,797 @@ +//! Per-profile "home" materialization — hermes-agent-style agent homes. +//! +//! Each profile can own an identity file (`SOUL.md`, re-read on every prompt +//! build), a curated per-profile memory file (`MEMORY.md`), an optional +//! dedicated memory subtree, and an optional agent-writable workspace. The two +//! roots are deliberately split (see [`crate::openhuman::config`]): +//! +//! ```text +//! /personalities//SOUL.md identity (hot-read each prompt) +//! /personalities//MEMORY.md curated per-profile memory +//! /profiles// agent-writable workspace +//! ``` +//! +//! Identity + memory files live under `workspace_dir` (core-managed reads only — +//! the agent's write tools cannot reach there, enforced fail-closed by +//! `is_workspace_internal_path`). The agent's *writable* working dir lives under +//! `action_dir`, which acting tools (shell/file/git) are allowed to touch. + +use std::io; +use std::path::{Path, PathBuf}; + +use super::types::{AgentProfile, DEFAULT_PROFILE_ID}; + +/// Directory holding a profile's core-managed identity + memory files: +/// `/personalities//`. +pub fn profile_home(workspace_dir: &Path, profile_id: &str) -> PathBuf { + workspace_dir.join("personalities").join(profile_id) +} + +/// The agent-writable per-profile workspace: `/profiles//`. +/// +/// Because this lives under `action_dir`, the `SecurityPolicy` already permits +/// acting tools to read/write here — no hardening change is needed. +pub fn profile_action_workspace(action_dir: &Path, profile_id: &str) -> PathBuf { + action_dir.join("profiles").join(profile_id) +} + +/// A profile's private skills directory: `/personalities//skills/`. +/// +/// SKILL.md / WORKFLOW.md bundles placed here are discovered ONLY for turns +/// running under this profile (see +/// `skills::discover_workflows_with_profile`). Seeded empty by +/// [`ensure_profile_home`]. +pub fn profile_skills_dir(workspace_dir: &Path, profile_id: &str) -> PathBuf { + profile_home(workspace_dir, profile_id).join("skills") +} + +/// The profile-local skills discovery root for `profile_id`, iff the id passes +/// [`validate_profile_id`]. +/// +/// The discovery/list seam (harness catalog build, `list_workflows` / +/// `describe_workflow` / resource-read tools) passes this into +/// `skills::discover_workflows_with_profile` / +/// `skills::load_workflow_metadata_for_profile`. Returns `None` for legacy ids +/// that fail validation — matching [`profile_skills_dir`]'s companion guards on +/// the home/workspace paths, so a home the read paths would never load never +/// contributes skills either. +pub fn profile_skills_root(workspace_dir: &Path, profile_id: &str) -> Option { + if let Err(e) = validate_profile_id(profile_id) { + tracing::debug!( + profile_id = %profile_id, + error = %e, + "[profiles][home] profile_skills_root: id fails validation, no profile-local skills root" + ); + return None; + } + Some(profile_skills_dir(workspace_dir, profile_id)) +} + +/// Validate a profile id against the hermes-style name grammar +/// `^[a-z0-9][a-z0-9_-]{0,63}$`. +/// +/// Only enforced when creating a *new* custom profile — legacy / built-in ids +/// (which may not satisfy the grammar) keep loading, so this is never applied on +/// the read/resolve paths. +pub fn validate_profile_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("profile id must not be empty".to_string()); + } + if id.len() > 64 { + return Err(format!("profile id '{id}' is too long (max 64 characters)")); + } + let mut chars = id.chars(); + let first = chars.next().expect("non-empty checked above"); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(format!( + "profile id '{id}' must start with a lowercase letter or digit" + )); + } + for c in id.chars() { + let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'; + if !ok { + return Err(format!( + "profile id '{id}' may only contain lowercase letters, digits, '_' or '-'" + )); + } + } + Ok(()) +} + +/// Write `contents` through a temp file in the same directory, then move it to +/// `target`. Unix `rename` atomically replaces an existing target. Windows +/// `rename` cannot overwrite, so rewrites remove the old target immediately +/// before the move; this is the platform-safe remove/replace fallback used by +/// Settings SOUL edits and clear tombstones. First-time seeds stay atomic on +/// every platform, and no reader can observe a partially-written file. +fn seed_file_atomic(dir: &Path, target: &Path, contents: &[u8]) -> io::Result<()> { + let base = target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("seed"); + let tmp = dir.join(format!(".{base}.tmp-{}", std::process::id())); + std::fs::write(&tmp, contents)?; + + #[cfg(windows)] + match std::fs::remove_file(target) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } + } + + match std::fs::rename(&tmp, target) { + Ok(()) => Ok(()), + Err(e) => { + // Best-effort cleanup so a failed rename doesn't leave a stray temp. + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// Render the default seed persona for a profile lacking an inline `soul_md`. +/// +/// Kept intentionally short — a real persona is authored by the user by editing +/// the seeded `SOUL.md`. Never contains PII or secrets. +fn default_soul_template(profile: &AgentProfile) -> String { + let name = if profile.name.trim().is_empty() { + profile.id.as_str() + } else { + profile.name.trim() + }; + let description = profile.description.trim(); + let mut out = format!("# {name}\n\n"); + if !description.is_empty() { + out.push_str(description); + out.push_str("\n\n"); + } + out.push_str(&format!( + "You are {name}. Keep your own voice, working style, and memory distinct \ + from other profiles. Edit this file to shape your identity.\n" + )); + out +} + +/// Materialize a profile's home on disk. Idempotent — existing files are never +/// overwritten, so a user's edited `SOUL.md` / `MEMORY.md` survive re-runs. +/// +/// Creates: +/// - `/personalities//` (always), +/// - `SOUL.md` seeded from `profile.soul_md` when non-empty, else a short +/// default persona template (only when the file is absent), +/// - an empty `MEMORY.md` (only when absent), +/// - `/profiles//` when `profile.dedicated_workspace`. +pub fn ensure_profile_home( + workspace_dir: &Path, + action_dir: &Path, + profile: &AgentProfile, +) -> io::Result<()> { + // Guard: only materialize a home for ids the read paths will actually load. + // `resolve_personality_soul`, `dedicated_workspace_dir`, and + // `effective_memory_suffix` all skip ids that fail `validate_profile_id`, so + // seeding `personalities//` for such an id would leave a home nothing + // ever reads. Early-return (no dir, no seed) to keep write/read symmetric. + if let Err(e) = validate_profile_id(&profile.id) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][home] ensure_profile_home skipped: id fails validation, \ + no home materialized (read paths would never load it)" + ); + return Ok(()); + } + + let home = profile_home(workspace_dir, &profile.id); + tracing::debug!( + profile_id = %profile.id, + home = %home.display(), + dedicated_memory = profile.dedicated_memory, + dedicated_workspace = profile.dedicated_workspace, + "[profiles][home] ensure_profile_home entry" + ); + std::fs::create_dir_all(&home).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + home = %home.display(), + error = %e, + "[profiles][home] create_dir_all home failed" + ); + e + })?; + + let soul_path = home.join("SOUL.md"); + let has_inline_soul = profile + .soul_md + .as_ref() + .is_some_and(|soul| !soul.trim().is_empty()); + if soul_path.exists() { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] SOUL.md already present, not overwriting" + ); + } else if profile.id == DEFAULT_PROFILE_ID && !has_inline_soul { + // Backward compatibility: the built-in Default profile represents the + // legacy workspace identity. Selecting it must not create a generic + // profile-local template that shadows the user's root SOUL.md. Once a + // user authors a Default soul it is seeded normally; an explicit clear + // writes an existing empty tombstone in `sync_soul_md_on_upsert`. + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] Default has no authored soul; preserving root SOUL.md fallback" + ); + } else { + let contents = profile + .soul_md + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + // Persist inline souls with a trailing newline for tidy files. + if s.ends_with('\n') { + s.to_string() + } else { + format!("{s}\n") + } + }) + .unwrap_or_else(|| default_soul_template(profile)); + let seeded_from_inline = has_inline_soul; + seed_file_atomic(&home, &soul_path, contents.as_bytes()).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + soul_path = %soul_path.display(), + error = %e, + "[profiles][home] seed SOUL.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + seeded_from_inline, + "[profiles][home] SOUL.md seeded" + ); + } + + let memory_path = home.join("MEMORY.md"); + if memory_path.exists() { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] MEMORY.md already present, not overwriting" + ); + } else { + seed_file_atomic(&home, &memory_path, b"").map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + memory_path = %memory_path.display(), + error = %e, + "[profiles][home] create empty MEMORY.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] MEMORY.md created (empty)" + ); + } + + // Profile-local skills root: `/personalities//skills/`. + // Created empty so the user has an obvious place to drop private SKILL.md + // bundles; discovery surfaces them only for this profile's turns. + let skills_dir = profile_skills_dir(workspace_dir, &profile.id); + std::fs::create_dir_all(&skills_dir).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + skills_dir = %skills_dir.display(), + error = %e, + "[profiles][home] create profile skills dir failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + skills_dir = %skills_dir.display(), + "[profiles][home] profile skills dir ensured" + ); + + if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { + std::fs::create_dir_all(&ws).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + workspace = %ws.display(), + error = %e, + "[profiles][home] create dedicated workspace failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + workspace = %ws.display(), + "[profiles][home] dedicated workspace ensured" + ); + } + + tracing::debug!(profile_id = %profile.id, "[profiles][home] ensure_profile_home ok"); + Ok(()) +} + +/// Reconcile the on-disk `SOUL.md` with an edited inline `soul_md` on an +/// **explicit profile save** (upsert only — never on select). +/// +/// [`ensure_profile_home`] seeds `SOUL.md` only when the file is *absent*, so a +/// persona edited in Settings after the home already exists would update +/// `agent_profiles.json` but leave the file stale — and because +/// [`resolve_personality_soul`](super::paths::resolve_personality_soul) reads +/// the file first, the agent would keep using the old identity. This closes that +/// gap by overwriting the file (atomic temp+rename, same as the seed path) when: +/// - the id passes [`validate_profile_id`] (read paths would load it), +/// - `profile.soul_md` is `Some(non-empty)` and differs from the previously +/// persisted inline value, and +/// - the trimmed inline content differs from the current file content. +/// +/// When `soul_md` was already empty/`None`, the file is left untouched so a +/// manual `SOUL.md` remains authoritative. A transition from a previously +/// non-empty inline value to empty replaces the synced file with an empty +/// tombstone, allowing the normal root fallback while preventing a later +/// `ensure_profile_home` call from re-seeding the default template. Only ever +/// called from the upsert path; select must not clobber a manually edited file +/// with a stale inline value. Returns `Ok(true)` when the file was rewritten. +pub fn sync_soul_md_on_upsert( + workspace_dir: &Path, + profile: &AgentProfile, + previous_soul_md: Option<&str>, +) -> io::Result { + if let Err(e) = validate_profile_id(&profile.id) { + tracing::debug!( + profile_id = %profile.id, + error = %e, + "[profiles][home] sync_soul_md_on_upsert skipped: id fails validation" + ); + return Ok(false); + } + let home = profile_home(workspace_dir, &profile.id); + let soul_path = home.join("SOUL.md"); + + // Clearing a previously persisted inline soul is an explicit Settings edit: + // replace the file that prior inline value created with an empty tombstone. + // `ensure_profile_home` treats that existing file as materialized, while + // `resolve_personality_soul` treats it as empty and falls back to root. + // If inline was already empty, preserve any manual file exactly as before. + let desired = match profile + .soul_md + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + Some(s) => s, + None => { + let previously_inline = previous_soul_md + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if previously_inline { + std::fs::create_dir_all(&home)?; + seed_file_atomic(&home, &soul_path, b"")?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: cleared synced SOUL.md with tombstone" + ); + return Ok(true); + } + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: inline soul_md empty, file left as-is" + ); + return Ok(false); + } + }; + + // The editor submits the complete profile on every save. If the inline + // soul did not change, this is an unrelated settings update and the + // hot-read file remains authoritative (it may have been edited manually + // since the profile was loaded). + if previous_soul_md.map(str::trim) == Some(desired) { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: inline soul_md unchanged, file left as-is" + ); + return Ok(false); + } + + // No-op when the file already matches the edited value (compare trimmed so a + // trailing-newline difference doesn't churn the file). + if let Ok(current) = std::fs::read_to_string(&soul_path) { + if current.trim() == desired { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: file already matches inline soul_md" + ); + return Ok(false); + } + } + + // Persist with a trailing newline for tidy files, matching the seed path. + let contents = format!("{desired}\n"); + std::fs::create_dir_all(&home)?; + seed_file_atomic(&home, &soul_path, contents.as_bytes()).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + soul_path = %soul_path.display(), + error = %e, + "[profiles][home] sync_soul_md_on_upsert: overwrite SOUL.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: SOUL.md overwritten from edited inline soul_md" + ); + Ok(true) +} + +/// Resolve the agent-writable workspace directory for a profile *iff* it opts +/// into a dedicated workspace and its id passes [`validate_profile_id`]. +/// +/// Returns `None` for shared-workspace profiles (the common case) and for +/// legacy ids that would fail id validation — in both cases the caller falls +/// back to the shared `action_dir`. This is the seam the session builder uses to +/// derive a per-profile default cwd (section D): a `WorkspaceDescriptor` rooted +/// at this path is threaded into the top-level chat turn. +pub fn dedicated_workspace_dir(action_dir: &Path, profile: &AgentProfile) -> Option { + if !profile.dedicated_workspace { + return None; + } + if let Err(e) = validate_profile_id(&profile.id) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][home] dedicated_workspace requested but id fails validation, \ + falling back to shared action_dir" + ); + return None; + } + Some(profile_action_workspace(action_dir, &profile.id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_profile(id: &str) -> AgentProfile { + let mut p = crate::openhuman::profiles::store::built_in_default_profile(); + p.id = id.to_string(); + p.name = id.to_string(); + p.built_in = false; + p.is_master = false; + p.memory_dir_suffix = None; + p.soul_md = None; + p.dedicated_memory = false; + p.dedicated_workspace = false; + p + } + + #[test] + fn profile_home_and_action_workspace_paths() { + let ws = Path::new("/tmp/ws"); + let action = Path::new("/tmp/act"); + assert_eq!( + profile_home(ws, "alice"), + Path::new("/tmp/ws/personalities/alice") + ); + assert_eq!( + profile_action_workspace(action, "alice"), + Path::new("/tmp/act/profiles/alice") + ); + } + + #[test] + fn validate_profile_id_matrix() { + // Valid. + let max_len = "x".repeat(64); + for id in [ + "a", + "a1", + "alice", + "alice-bob", + "alice_bob", + "0", + max_len.as_str(), + ] { + assert!(validate_profile_id(id).is_ok(), "expected ok: {id}"); + } + // Invalid. + for id in [ + "", // empty + "-alice", // leading dash + "_alice", // leading underscore + "Alice", // uppercase + "alice bob", // space + "alice.bob", // dot + "alice/bob", // slash + "über", // non-ascii + ] { + assert!(validate_profile_id(id).is_err(), "expected err: {id}"); + } + // Too long (65). + assert!(validate_profile_id(&"a".repeat(65)).is_err()); + } + + #[test] + fn ensure_profile_home_creates_and_seeds() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("alice"); + profile.description = "A tidy writer.".to_string(); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + let home = profile_home(ws.path(), "alice"); + assert!(home.is_dir()); + let soul = std::fs::read_to_string(home.join("SOUL.md")).unwrap(); + // Default template used (no inline soul_md). + assert!(soul.contains("alice")); + assert!(soul.contains("A tidy writer.")); + assert!(home.join("MEMORY.md").exists()); + // No dedicated workspace requested. + assert!(!profile_action_workspace(action.path(), "alice").exists()); + } + + #[test] + fn ensure_profile_home_seeds_soul_from_inline() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("bob"); + profile.soul_md = Some("I am Bob, terse and exact.".to_string()); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul = std::fs::read_to_string(profile_home(ws.path(), "bob").join("SOUL.md")).unwrap(); + assert_eq!(soul, "I am Bob, terse and exact.\n"); + } + + #[test] + fn ensure_default_profile_without_authored_soul_preserves_root_fallback() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + std::fs::write(ws.path().join("SOUL.md"), "Established root identity").unwrap(); + let mut profile = test_profile(DEFAULT_PROFILE_ID); + profile.built_in = true; + profile.soul_md = None; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + assert!(profile_home(ws.path(), DEFAULT_PROFILE_ID) + .join("MEMORY.md") + .exists()); + assert!(!profile_home(ws.path(), DEFAULT_PROFILE_ID) + .join("SOUL.md") + .exists()); + assert_eq!( + super::super::paths::resolve_personality_soul(ws.path(), &profile), + None, + "no profile override leaves prompt construction on the root SOUL.md" + ); + } + + #[test] + fn ensure_profile_home_is_idempotent_and_preserves_edits() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let profile = test_profile("carol"); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure 1"); + let home = profile_home(ws.path(), "carol"); + // User edits both files. + std::fs::write(home.join("SOUL.md"), "EDITED SOUL").unwrap(); + std::fs::write(home.join("MEMORY.md"), "EDITED MEMORY").unwrap(); + + // Second run must not clobber. + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure 2"); + assert_eq!( + std::fs::read_to_string(home.join("SOUL.md")).unwrap(), + "EDITED SOUL" + ); + assert_eq!( + std::fs::read_to_string(home.join("MEMORY.md")).unwrap(), + "EDITED MEMORY" + ); + } + + #[test] + fn ensure_profile_home_creates_empty_skills_dir() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let profile = test_profile("frank"); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + let skills = profile_skills_dir(ws.path(), "frank"); + assert!(skills.is_dir(), "profile skills dir must be created"); + // Empty — the user drops private SKILL.md bundles here. + assert_eq!(std::fs::read_dir(&skills).unwrap().count(), 0); + } + + #[test] + fn profile_skills_dir_and_root_paths() { + let ws = Path::new("/tmp/ws"); + assert_eq!( + profile_skills_dir(ws, "alice"), + Path::new("/tmp/ws/personalities/alice/skills") + ); + // Valid id → Some(root); invalid id → None (read paths never load it). + assert_eq!( + profile_skills_root(ws, "alice"), + Some(Path::new("/tmp/ws/personalities/alice/skills").to_path_buf()) + ); + assert_eq!(profile_skills_root(ws, "Bad Id"), None); + } + + #[test] + fn ensure_profile_home_creates_dedicated_workspace_when_opted_in() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("dave"); + profile.dedicated_workspace = true; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + assert!(profile_action_workspace(action.path(), "dave").is_dir()); + } + + #[test] + fn ensure_profile_home_skips_invalid_id() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + // An id that fails validate_profile_id must not materialize a home — the + // read paths would never load it, so a seeded dir would be dead weight. + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.dedicated_workspace = true; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + assert!( + !profile_home(ws.path(), "Bad Id").exists(), + "no home dir should be materialized for an invalid id" + ); + assert!( + !profile_action_workspace(action.path(), "Bad Id").exists(), + "no dedicated workspace should be materialized for an invalid id" + ); + // The `personalities/` root itself must not be created for it either. + assert!(!ws.path().join("personalities").join("Bad Id").exists()); + } + + #[test] + fn sync_soul_md_on_upsert_overwrites_edited_inline_soul() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("grace"); + profile.soul_md = Some("Original identity.".to_string()); + // Seed the home once (writes SOUL.md from the original inline value). + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "grace").join("SOUL.md"); + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Original identity.\n" + ); + + // User edits the persona in Settings → the stored inline value changes. + profile.soul_md = Some("Rewritten identity from Settings.".to_string()); + let rewritten = + sync_soul_md_on_upsert(ws.path(), &profile, Some("Original identity.")).expect("sync"); + assert!( + rewritten, + "differing inline soul_md must overwrite the file" + ); + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Rewritten identity from Settings.\n" + ); + + // Idempotent: a second sync with the same value is a no-op. + let again = sync_soul_md_on_upsert( + ws.path(), + &profile, + Some("Rewritten identity from Settings."), + ) + .expect("sync 2"); + assert!(!again, "matching inline soul_md must not rewrite the file"); + } + + #[test] + fn sync_soul_md_on_upsert_leaves_file_when_inline_empty() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + // Seed with a default template (no inline soul_md). + let mut profile = test_profile("heidi"); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "heidi").join("SOUL.md"); + // User edits the file manually; inline soul_md stays empty/None. + std::fs::write(&soul_path, "MANUALLY EDITED SOUL").unwrap(); + + profile.soul_md = None; + let none_written = sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync none"); + assert!(!none_written); + profile.soul_md = Some(" ".to_string()); // whitespace-only → treated as empty + let blank_written = sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync blank"); + assert!(!blank_written); + + // The manual edit stays authoritative. + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "MANUALLY EDITED SOUL" + ); + } + + #[test] + fn sync_soul_md_on_upsert_persists_empty_tombstone_when_inline_is_cleared() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("judy"); + profile.soul_md = Some("Settings identity".to_string()); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "judy").join("SOUL.md"); + assert!(soul_path.exists()); + + profile.soul_md = None; + assert!( + sync_soul_md_on_upsert(ws.path(), &profile, Some("Settings identity")) + .expect("clear synced soul") + ); + assert_eq!(std::fs::read_to_string(&soul_path).unwrap(), ""); + + // Selecting/materializing the profile again must not resurrect the + // default profile template over the explicit clear. + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure after clear"); + assert_eq!(std::fs::read_to_string(&soul_path).unwrap(), ""); + } + + #[test] + fn sync_soul_md_on_upsert_skips_invalid_id() { + let ws = TempDir::new().unwrap(); + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.soul_md = Some("ignored".to_string()); + assert!(!sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync")); + assert!(!profile_home(ws.path(), "Bad Id").join("SOUL.md").exists()); + } + + #[test] + fn sync_soul_md_on_upsert_preserves_manual_file_when_inline_unchanged() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("ivy"); + profile.soul_md = Some("Stored identity.".to_string()); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "ivy").join("SOUL.md"); + std::fs::write(&soul_path, "MANUALLY EDITED IDENTITY\n").unwrap(); + + let rewritten = sync_soul_md_on_upsert(ws.path(), &profile, Some("Stored identity.")) + .expect("sync unchanged"); + + assert!(!rewritten); + assert_eq!( + std::fs::read_to_string(soul_path).unwrap(), + "MANUALLY EDITED IDENTITY\n" + ); + } + + #[test] + fn dedicated_workspace_dir_gates_on_flag_and_id() { + let action = Path::new("/tmp/act"); + let mut shared = test_profile("eve"); + assert_eq!(dedicated_workspace_dir(action, &shared), None); + + shared.dedicated_workspace = true; + assert_eq!( + dedicated_workspace_dir(action, &shared), + Some(Path::new("/tmp/act/profiles/eve").to_path_buf()) + ); + + // Legacy invalid id + dedicated_workspace → None (falls back to shared). + let mut legacy = test_profile("Legacy Id"); + legacy.id = "Legacy Id".to_string(); + legacy.dedicated_workspace = true; + assert_eq!(dedicated_workspace_dir(action, &legacy), None); + } +} diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index cce146a6d9..8196fc6dec 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -8,7 +8,51 @@ //! //! Relocated from `openhuman::agent::profiles` / `::personality_paths` so the //! domain is addressable on its own (`openhuman::profiles`). +//! +//! # Profile home layout (hermes-agent style) +//! +//! Beyond the shared JSON store, each profile can own an on-disk "home" — an +//! identity file, curated memory, an optional dedicated memory subtree, and an +//! optional agent-writable workspace. The two path roots are deliberately split +//! (see [`crate::openhuman::config`]): core-managed identity/memory files live +//! under `workspace_dir` (which the agent's write tools cannot reach), while the +//! agent's writable working dir lives under `action_dir`. +//! +//! ```text +//! /personalities//SOUL.md identity (hot-read each prompt) +//! /personalities//MEMORY.md curated per-profile memory +//! /personalities//skills/ private skills (owner-only discovery) +//! /{memory,memory_tree,session_raw}-/ dedicated memory subtree (opt-in) +//! /profiles// agent-writable workspace (opt-in) +//! ``` +//! +//! - `SOUL.md` is re-read on every prompt build (see +//! [`resolve_personality_soul`]) so identity edits take effect live. +//! - `dedicated_memory` derives a `-` memory suffix (see +//! [`effective_memory_suffix`]) and, as an explicit user opt-in, wins over the +//! store's auto-assigned numeric `memory_dir_suffix`; the numeric suffix is +//! retained only when `dedicated_memory` is off (back-compat). +//! - `dedicated_workspace` roots a per-profile default cwd for acting tools (see +//! [`dedicated_workspace_dir`] and the session builder's section-D wiring). +//! - `skills/` (see [`profile_skills_dir`]) holds SKILL.md/WORKFLOW.md bundles +//! private to this profile: discovered ONLY for turns running under it, +//! implicitly allowed for their owner, and winning same-name collisions +//! against global skills (`skills::discover_workflows_with_profile`). Advertised +//! read-only as `skillsDir` in the enriched RPC payload when present. +//! - [`ensure_profile_home`] materializes the home idempotently (never +//! overwriting a user's edited files) on upsert/select — including the empty +//! `skills/` dir. +//! +//! # Cron attribution +//! +//! A cron job may carry a `profile_id` (`cron::CronJob::profile_id`). When it is +//! set and the profile still exists, the scheduled run is built under that +//! profile (soul, memory scope, dedicated workspace, allowlists) via the same +//! profile-aware session path the task dispatcher uses; a deleted profile falls +//! back to a profile-less run rather than failing the job. +pub mod guard; +pub mod home; pub mod ops; pub mod paths; pub mod prompt_section; @@ -16,12 +60,20 @@ mod schemas; pub mod store; pub mod types; +pub use guard::{ + classify_cross_profile_target, profile_id_from_policy_id, scan_command_for_cross_profile, + workspace_policy_id, CrossProfileDecision, PROFILES_ROOT_SENTINEL, +}; +pub use home::{ + dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, + profile_skills_dir, profile_skills_root, validate_profile_id, +}; pub use paths::{ - filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, - resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, - HasToolkit, PersonalityContext, + effective_memory_suffix, filter_integrations, memory_subdir_for_suffix, + memory_tree_subdir_for_suffix, profile_session_signature, resolve_personality_memory_md, + resolve_personality_soul, session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, }; -pub use prompt_section::AgentProfilePromptSection; +pub use prompt_section::{cross_profile_workspace_notice, AgentProfilePromptSection}; pub use store::{built_in_profiles, load_profiles, AgentProfileStore}; pub use types::{profile_signature, AgentProfile, AgentProfilesState, DEFAULT_PROFILE_ID}; diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index 2dbe176a31..fbfe512822 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -6,31 +6,121 @@ //! payload the controller emits. The persistence itself is owned by //! [`AgentProfileStore`](super::store::AgentProfileStore). -use serde_json::Value; +use std::path::{Path, PathBuf}; +use serde_json::{Map, Value}; + +use super::home::{ + dedicated_workspace_dir, ensure_profile_home, profile_home, validate_profile_id, +}; use super::store::AgentProfileStore; use super::types::{AgentProfile, AgentProfilesState}; use crate::openhuman::config::rpc as config_rpc; -/// Shape the `{ profiles, activeProfileId }` payload every profiles RPC returns. -fn state_payload(state: &AgentProfilesState) -> Value { +/// Enrich one stored profile with resolved, read-only path info the UI shows but +/// the [`AgentProfile`] struct never persists (section E): +/// - `soulMdFile`: absolute path of `personalities//SOUL.md` when it exists; +/// - `workspaceDir`: absolute path of the dedicated workspace when opted in. +/// +/// Derived on read from the two path roots — never round-tripped back into the +/// store, so the stored payload stays clean. +fn enrich_profile(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfile) -> Value { + let mut obj: Map = match serde_json::to_value(profile) { + Ok(Value::Object(map)) => map, + _ => { + // A profile must serialize to a JSON object; anything else drops every + // field. This should be unreachable for `AgentProfile`, so warn (but + // keep the empty-map fallback so the RPC still returns a value). + tracing::warn!( + profile_id = %profile.id, + "[profiles][ops] enrich_profile: profile did not serialize to a JSON \ + object; enrichment yields an empty object" + ); + Map::new() + } + }; + // Skip the derived path enrichment for ids the read paths would never load + // (they fail `validate_profile_id`). `dedicated_workspace_dir` already gates + // on the same check, so `workspaceDir` was never emitted for them; matching + // that for `soulMdFile` keeps the advertised paths symmetric with what the + // core will actually read, and — paired with the `ensure_profile_home` guard + // — such an id has no SOUL.md on disk to advertise anyway. + if validate_profile_id(&profile.id).is_err() { + return Value::Object(obj); + } + let soul = profile_home(workspace_dir, &profile.id).join("SOUL.md"); + if soul.exists() { + obj.insert( + "soulMdFile".to_string(), + Value::String(soul.to_string_lossy().into_owned()), + ); + } + // 2a — advertise the profile-local skills dir when it exists on disk (seeded + // by `ensure_profile_home`). Read-only, derived, never persisted. The UI + // (Phase 3) surfaces it as "skills placed here are private to this profile". + let skills_dir = super::home::profile_skills_dir(workspace_dir, &profile.id); + if skills_dir.is_dir() { + obj.insert( + "skillsDir".to_string(), + Value::String(skills_dir.to_string_lossy().into_owned()), + ); + } + if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { + obj.insert( + "workspaceDir".to_string(), + Value::String(ws.to_string_lossy().into_owned()), + ); + } + Value::Object(obj) +} + +/// Shape the `{ profiles, activeProfileId }` payload every profiles RPC returns, +/// enriching each profile with its resolved read-only path info. +fn enriched_state_payload( + workspace_dir: &Path, + action_dir: &Path, + state: &AgentProfilesState, +) -> Value { + let profiles: Vec = state + .profiles + .iter() + .map(|p| enrich_profile(workspace_dir, action_dir, p)) + .collect(); serde_json::json!({ - "profiles": state.profiles, + "profiles": profiles, "activeProfileId": state.active_profile_id, }) } -/// Resolve the workspace-scoped profile store from the loaded config. -async fn store() -> Result { +/// Resolve the workspace-scoped profile store plus the `(workspace, action)` +/// path roots from a single config load, so the id-scoped store call, the home +/// materialization, and payload enrichment all share one load. +async fn store_and_roots() -> Result<(AgentProfileStore, PathBuf, PathBuf), String> { let config = config_rpc::load_config_with_timeout().await?; - Ok(AgentProfileStore::new(config.workspace_dir)) + let store = AgentProfileStore::new(config.workspace_dir.clone()); + Ok((store, config.workspace_dir, config.action_dir)) +} + +/// Best-effort materialization of a profile's home directory. Logs and swallows +/// filesystem errors — a failed home seed must never fail the RPC (the profile +/// is already persisted; the identity/memory files are lazily re-created on the +/// next select/upsert or read). +fn materialize_home(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfile) { + if let Err(e) = ensure_profile_home(workspace_dir, action_dir, profile) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][ops] ensure_profile_home failed (non-fatal)" + ); + } } /// List all persistent profiles and the active id. pub async fn list() -> Result { let request_id = format!("profiles-list-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, "[profiles][ops] list entry"); - let state = store().await?.load().map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.load().map_err(|e| { tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] list error"); e })?; @@ -40,24 +130,34 @@ pub async fn list() -> Result { profile_count = state.profiles.len(), "[profiles][ops] list ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Select the active profile by id. pub async fn select(profile_id: &str) -> Result { let request_id = format!("profile-select-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] select entry"); - let state = store().await?.select(profile_id).map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.select(profile_id).map_err(|e| { tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] select error"); e })?; + // Materialize the selected profile's home (covers built-ins, which are only + // seeded when a user first activates them). + if let Some(profile) = state + .profiles + .iter() + .find(|p| p.id == state.active_profile_id) + { + materialize_home(&workspace_dir, &action_dir, profile); + } tracing::debug!( request_id = %request_id, profile_id, active_profile_id = %state.active_profile_id, "[profiles][ops] select ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Create or update a profile. @@ -104,24 +204,74 @@ pub async fn upsert(profile: AgentProfile) -> Result { } } } - let state = store().await?.upsert(profile).map_err(|e| { + let upserted_id = profile.id.clone(); + let (store, workspace_dir, action_dir) = store_and_roots().await?; + // Keep the previous inline value so SOUL.md is rewritten only when the + // persona field itself changed. The editor submits the full profile for + // unrelated settings saves; comparing only against the file would clobber + // a newer manual edit with the unchanged, stale inline value. + let normalised_id = super::store::normalise_profile_id(&upserted_id); + let previous_soul_md = store + .load() + .map_err(|e| { + tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert preload error"); + e + })? + .profiles + .into_iter() + .find(|p| p.id == normalised_id) + .and_then(|p| p.soul_md); + let state = store.upsert(profile).map_err(|e| { tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert error"); e })?; + // Materialize the home for the just-upserted profile. The store normalises + // the id (slugify), so resolve the persisted profile from the returned state + // rather than trusting the raw input id. Built-ins are seeded on select, not + // here, matching the spec. + if let Some(persisted) = state.profiles.iter().find(|p| p.id == normalised_id) { + // Full home materialization (SOUL.md seed + MEMORY.md + skills/ + + // dedicated workspace) is for CUSTOM profiles here; built-ins are seeded + // on `select` (first activation), matching the spec. + if !persisted.built_in { + materialize_home(&workspace_dir, &action_dir, persisted); + } + // Reconcile an edited persona into the on-disk SOUL.md for EVERY profile, + // built-in included. `ensure_profile_home` only seeds SOUL.md when absent, + // and `select` seeds built-in homes on first activation — so a user who + // selects Default/Research once and later edits its Soul in Settings would + // otherwise keep a stale `personalities//SOUL.md` that + // `resolve_personality_soul` reads before the inline value. The sync is a + // no-op when `soul_md` is empty/None or unchanged (manual file edits + // stay authoritative) and creates the home dir if needed. Non-fatal — + // the profile is already persisted. + if let Err(e) = super::home::sync_soul_md_on_upsert( + &workspace_dir, + persisted, + previous_soul_md.as_deref(), + ) { + tracing::warn!( + profile_id = %persisted.id, + error = %e, + "[profiles][ops] sync_soul_md_on_upsert failed (non-fatal)" + ); + } + } tracing::debug!( request_id = %request_id, active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), "[profiles][ops] upsert ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Delete a custom profile by id. pub async fn delete(profile_id: &str) -> Result { let request_id = format!("profile-delete-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] delete entry"); - let state = store().await?.delete(profile_id).map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.delete(profile_id).map_err(|e| { tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] delete error"); e })?; @@ -132,7 +282,7 @@ pub async fn delete(profile_id: &str) -> Result { profile_count = state.profiles.len(), "[profiles][ops] delete ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// The implicit orchestrator agent that requires no registry entry — the @@ -168,6 +318,27 @@ mod tests { } } + struct ActionDirEnvGuard { + previous: Option, + } + impl ActionDirEnvGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_ACTION_DIR"); + unsafe { + std::env::set_var("OPENHUMAN_ACTION_DIR", path); + } + Self { previous } + } + } + impl Drop for ActionDirEnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => unsafe { std::env::set_var("OPENHUMAN_ACTION_DIR", value) }, + None => unsafe { std::env::remove_var("OPENHUMAN_ACTION_DIR") }, + } + } + } + fn profile(id: &str, agent_id: &str) -> AgentProfile { let mut p = super::super::store::built_in_default_profile(); p.id = id.to_string(); @@ -179,6 +350,122 @@ mod tests { p } + #[tokio::test] + async fn upsert_materializes_home_and_list_enriches_paths() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let _env_ws = WorkspaceEnvGuard::set(ws.path()); + let _env_action = ActionDirEnvGuard::set(action.path()); + + let mut p = profile("writer", "orchestrator"); + p.dedicated_workspace = true; + let out = upsert(p).await.expect("upsert"); + + // The enriched payload surfaces resolved read-only paths. `soulMdFile` is + // only inserted when the file exists on disk, so its presence proves the + // home was materialized (workspace_dir is config-derived, so we assert on + // the resolved suffix rather than the raw temp root). `workspaceDir` is + // present only for a `dedicated_workspace` profile, proving the opt-in + // dir path was resolved and created. + let writer = out["profiles"] + .as_array() + .expect("profiles array") + .iter() + .find(|p| p["id"] == "writer") + .expect("writer profile present"); + let soul_file = writer["soulMdFile"] + .as_str() + .expect("soulMdFile present (SOUL.md was seeded on disk)"); + assert!( + soul_file.ends_with("personalities/writer/SOUL.md"), + "soulMdFile should end at the profile home, got {soul_file}" + ); + // The resolved SOUL.md really exists on disk. + assert!(std::path::Path::new(soul_file).exists()); + let workspace_dir = writer["workspaceDir"] + .as_str() + .expect("workspaceDir present for dedicated_workspace profile"); + assert!( + workspace_dir.ends_with("profiles/writer"), + "workspaceDir should end at the profile workspace, got {workspace_dir}" + ); + // The dedicated workspace dir was actually created. + assert!(std::path::Path::new(workspace_dir).is_dir()); + assert_eq!(writer["dedicatedWorkspace"], json!(true)); + // 2a — the profile-local skills dir is seeded by `ensure_profile_home` + // and advertised read-only as `skillsDir`. + let skills_dir = writer["skillsDir"] + .as_str() + .expect("skillsDir present (skills dir was seeded on disk)"); + assert!( + skills_dir.ends_with("personalities/writer/skills"), + "skillsDir should end at the profile skills dir, got {skills_dir}" + ); + assert!(std::path::Path::new(skills_dir).is_dir()); + } + + #[tokio::test] + async fn shared_profile_has_no_workspace_dir_in_payload() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let _env_ws = WorkspaceEnvGuard::set(ws.path()); + let _env_action = ActionDirEnvGuard::set(action.path()); + + // A shared-workspace profile: soulMdFile resolves (home is seeded) but no + // workspaceDir key is present. + let out = upsert(profile("shared", "orchestrator")) + .await + .expect("upsert"); + let shared = out["profiles"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "shared") + .expect("shared profile present"); + // A shared-workspace profile never surfaces workspaceDir, but its home + // (SOUL.md) is still seeded, so soulMdFile resolves. + assert!(shared.get("workspaceDir").is_none()); + assert!(shared["soulMdFile"].as_str().is_some()); + } + + #[test] + fn enrich_profile_skips_paths_for_invalid_id() { + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let mut p = profile("placeholder", "orchestrator"); + p.id = "Bad Id".to_string(); + p.dedicated_workspace = true; + + // Even if a SOUL.md somehow exists at the would-be home, an invalid id must + // not advertise soulMdFile/workspaceDir — the read paths would never load + // it, so the enriched payload must stay symmetric with what core reads. + let home = super::profile_home(ws.path(), "Bad Id"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "x").unwrap(); + + let enriched = super::enrich_profile(ws.path(), action.path(), &p); + assert!( + enriched.get("soulMdFile").is_none(), + "invalid id must not advertise soulMdFile even when the file exists" + ); + assert!( + enriched.get("workspaceDir").is_none(), + "invalid id must not advertise workspaceDir" + ); + + // Control: a valid id with the same on-disk SOUL.md does advertise it. + let mut valid = p.clone(); + valid.id = "goodid".to_string(); + let valid_home = super::profile_home(ws.path(), "goodid"); + std::fs::create_dir_all(&valid_home).unwrap(); + std::fs::write(valid_home.join("SOUL.md"), "x").unwrap(); + let enriched_valid = super::enrich_profile(ws.path(), action.path(), &valid); + assert!(enriched_valid["soulMdFile"].as_str().is_some()); + assert!(enriched_valid["workspaceDir"].as_str().is_some()); + } + #[tokio::test] async fn upsert_default_agent_allowed_without_registry() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/openhuman/profiles/paths.rs b/src/openhuman/profiles/paths.rs index f6ab476c39..a6c0ac674f 100644 --- a/src/openhuman/profiles/paths.rs +++ b/src/openhuman/profiles/paths.rs @@ -1,7 +1,9 @@ //! Personality-scoped path resolution and context for multi-agent sessions. +use std::hash::{Hash, Hasher}; use std::path::{Component, Path}; +use super::home::validate_profile_id; use super::types::AgentProfile; /// Reject path strings that could escape the workspace: absolute paths, @@ -46,11 +48,55 @@ pub fn session_raw_subdir_for_suffix(suffix: &str) -> String { /// Resolve the SOUL.md content for a personality. /// -/// Resolution order: -/// 1. `soul_md_path` — read the file at that relative path under workspace. -/// 2. `soul_md` — inline content from the profile. -/// 3. `None` — caller falls back to the workspace root `SOUL.md`. +/// Resolution order (hermes-style — the per-profile identity file wins and is +/// re-read on every prompt build): +/// 1. `personalities//SOUL.md` — the canonical per-profile identity file +/// (skipped when the profile id fails [`validate_profile_id`], so legacy +/// profiles with arbitrary ids can't construct an unexpected path). +/// 2. `soul_md_path` — read the file at that relative path under workspace. +/// 3. `soul_md` — inline content from the profile. +/// 4. `None` — caller falls back to the workspace root `SOUL.md`. pub fn resolve_personality_soul(workspace_dir: &Path, profile: &AgentProfile) -> Option { + // Step 1: the per-profile home SOUL.md. Only attempted for ids that pass the + // hermes name grammar — a legacy/built-in id that fails validation skips + // straight to the existing (2)/(3)/(4) resolution below, unchanged. + match validate_profile_id(&profile.id) { + Ok(()) => { + let home_soul = super::home::profile_home(workspace_dir, &profile.id).join("SOUL.md"); + match std::fs::read_to_string(&home_soul) { + Ok(content) if !content.trim().is_empty() => { + tracing::debug!( + path = %home_soul.display(), + profile_id = %profile.id, + "[personality] soul_md loaded from profile home" + ); + return Some(content); + } + Ok(_) => { + tracing::debug!( + profile_id = %profile.id, + "[personality] profile-home SOUL.md empty, trying soul_md_path/inline" + ); + } + Err(e) => { + tracing::debug!( + path = %home_soul.display(), + profile_id = %profile.id, + error = %e, + "[personality] profile-home SOUL.md absent, trying soul_md_path/inline" + ); + } + } + } + Err(e) => { + tracing::debug!( + profile_id = %profile.id, + error = %e, + "[personality] profile id fails validation, skipping profile-home SOUL.md" + ); + } + } + if let Some(ref rel_path) = profile.soul_md_path { let rel = Path::new(rel_path); if !is_safe_relative_path(rel) { @@ -160,6 +206,82 @@ pub fn resolve_personality_memory_md( } } +/// Fingerprint every profile input baked into a cached session agent. +/// +/// The profile record alone is insufficient because users may edit the +/// canonical SOUL.md or MEMORY.md files directly. Hashing their resolved +/// contents makes the next web-chat turn rebuild its cached agent without +/// retaining the files themselves in cache metadata or logs. +pub fn profile_session_signature(workspace_dir: &Path, profile: &AgentProfile) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + super::types::profile_signature(profile).hash(&mut hasher); + resolve_personality_soul(workspace_dir, profile).hash(&mut hasher); + resolve_personality_memory_md(workspace_dir, profile).hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// Derive the effective memory directory suffix for a profile. +/// +/// Precedence: +/// 1. when `dedicated_memory` is set, derive `"-"` from the profile id +/// (id must pass [`validate_profile_id`], else fall back to the shared `""` +/// and warn — a legacy id can't mint an unexpected directory name). This is +/// an explicit user opt-in and **wins over** the auto-assigned numeric +/// suffix: the store stamps every non-default profile with `Some("-1")`, +/// `Some("-2")`, … on upsert, so if the numeric suffix took precedence the +/// isolation toggle could never take effect (it would be dead code). Toggling +/// `dedicated_memory` on therefore switches the profile to its own +/// `memory-` subtree — the intended behaviour of the toggle. +/// 2. else, an explicit `memory_dir_suffix` (the legacy auto-assigned numeric +/// suffix, e.g. `"-1"`) — pre-existing non-dedicated profiles keep their +/// directories; +/// 3. else `""` (the shared/global memory tree). +/// +/// The returned suffix feeds the existing +/// [`memory_subdir_for_suffix`] / [`memory_tree_subdir_for_suffix`] / +/// [`session_raw_subdir_for_suffix`] helpers unchanged. +pub fn effective_memory_suffix(profile: &AgentProfile) -> String { + if profile.dedicated_memory { + match validate_profile_id(&profile.id) { + Ok(()) => { + let suffix = format!("-{}", profile.id); + tracing::debug!( + profile_id = %profile.id, + suffix = %suffix, + "[personality] effective_memory_suffix derived from dedicated_memory" + ); + return suffix; + } + Err(e) => { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[personality] dedicated_memory requested but id fails validation, \ + falling back to legacy/shared memory tree" + ); + } + } + } + if let Some(suffix) = profile + .memory_dir_suffix + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + tracing::debug!( + profile_id = %profile.id, + suffix = %suffix, + "[personality] effective_memory_suffix using legacy numeric suffix" + ); + return suffix.to_string(); + } + tracing::debug!( + profile_id = %profile.id, + "[personality] effective_memory_suffix using shared memory tree" + ); + String::new() +} + /// All personality-resolved overrides needed to build a scoped agent session. #[derive(Debug, Clone)] pub struct PersonalityContext { @@ -174,7 +296,7 @@ pub struct PersonalityContext { impl PersonalityContext { /// Build from a resolved `AgentProfile`, reading personality files from the workspace. pub fn from_profile(workspace_dir: &Path, profile: AgentProfile) -> Self { - let memory_suffix = profile.memory_dir_suffix.clone().unwrap_or_default(); + let memory_suffix = effective_memory_suffix(&profile); let soul_md_override = resolve_personality_soul(workspace_dir, &profile); let memory_md_override = resolve_personality_memory_md(workspace_dir, &profile); let composio_allowlist = profile.composio_integrations.clone(); @@ -253,6 +375,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -276,6 +400,120 @@ mod tests { assert_eq!(session_raw_subdir_for_suffix("-1"), "session_raw-1"); } + #[test] + fn resolve_soul_prefers_profile_home_file() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "Home identity for Alice").unwrap(); + + let mut profile = test_profile("alice"); + // Both inline and soul_md_path present — the profile-home file still wins. + profile.soul_md = Some("Inline soul".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Home identity for Alice")); + } + + #[test] + fn resolve_soul_skips_home_file_for_invalid_legacy_id() { + let tmp = TempDir::new().unwrap(); + // A legacy id that fails validate_profile_id (space + uppercase). + let legacy_id = "Legacy Id"; + let home = tmp.path().join("personalities").join(legacy_id); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "SHOULD BE SKIPPED").unwrap(); + + let mut profile = test_profile("placeholder"); + profile.id = legacy_id.to_string(); + profile.soul_md = Some("Legacy inline soul".to_string()); + // Step 1 (profile-home SOUL.md) is skipped for the invalid id; resolution + // falls through to the inline value. + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Legacy inline soul")); + } + + #[test] + fn resolve_soul_empty_home_file_falls_through_to_inline() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), " \n").unwrap(); // whitespace only + + let mut profile = test_profile("alice"); + profile.soul_md = Some("Inline wins over empty home file".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Inline wins over empty home file")); + } + + #[test] + fn effective_memory_suffix_dedicated_wins_over_numeric() { + let mut profile = test_profile("alice"); + // The store auto-assigns a numeric suffix to every non-default profile, + // so `dedicated_memory` must win over it — otherwise the toggle would be + // dead code and could never route to the `memory-` subtree. + profile.memory_dir_suffix = Some("-3".to_string()); + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-alice"); + } + + #[test] + fn effective_memory_suffix_numeric_retained_when_not_dedicated() { + let mut profile = test_profile("alice"); + // With dedicated_memory off, the persisted legacy numeric suffix is + // retained so an existing memory directory is never orphaned. + profile.memory_dir_suffix = Some("-3".to_string()); + profile.dedicated_memory = false; + assert_eq!(effective_memory_suffix(&profile), "-3"); + } + + #[test] + fn effective_memory_suffix_invalid_id_dedicated_falls_back_to_numeric() { + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + // An invalid id can't mint a `-` directory even with dedicated on, so + // it falls back to the persisted numeric suffix rather than the shared + // tree. + profile.memory_dir_suffix = Some("-2".to_string()); + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-2"); + } + + #[test] + fn effective_memory_suffix_dedicated_derives_from_id() { + let mut profile = test_profile("alice"); + profile.memory_dir_suffix = None; + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-alice"); + } + + #[test] + fn effective_memory_suffix_shared_default() { + let mut profile = test_profile("alice"); + profile.memory_dir_suffix = None; + profile.dedicated_memory = false; + assert_eq!(effective_memory_suffix(&profile), ""); + } + + #[test] + fn effective_memory_suffix_invalid_id_falls_back_to_shared() { + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.memory_dir_suffix = None; + profile.dedicated_memory = true; + // Invalid id cannot mint a directory name — fall back to shared "". + assert_eq!(effective_memory_suffix(&profile), ""); + } + + #[test] + fn effective_memory_suffix_empty_string_suffix_is_not_legacy() { + let mut profile = test_profile("alice"); + // The default profile stores Some("") — treated as "no legacy suffix", so + // dedicated_memory (if set) still derives, else shared. + profile.memory_dir_suffix = Some(String::new()); + profile.dedicated_memory = false; + assert_eq!(effective_memory_suffix(&profile), ""); + } + #[test] fn resolve_soul_inline_fallback() { let tmp = TempDir::new().unwrap(); @@ -331,6 +569,25 @@ mod tests { assert!(result.is_none()); } + #[test] + fn profile_session_signature_tracks_profile_file_edits() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities/alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "first soul").unwrap(); + std::fs::write(home.join("MEMORY.md"), "first memory").unwrap(); + let profile = test_profile("alice"); + + let original = profile_session_signature(tmp.path(), &profile); + std::fs::write(home.join("SOUL.md"), "second soul").unwrap(); + let after_soul_edit = profile_session_signature(tmp.path(), &profile); + assert_ne!(original, after_soul_edit); + + std::fs::write(home.join("MEMORY.md"), "second memory").unwrap(); + let after_memory_edit = profile_session_signature(tmp.path(), &profile); + assert_ne!(after_soul_edit, after_memory_edit); + } + #[test] fn personality_context_from_profile() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs index 5257d4e00e..6c7fc93b50 100644 --- a/src/openhuman/profiles/prompt_section.rs +++ b/src/openhuman/profiles/prompt_section.rs @@ -1,17 +1,50 @@ -//! Prompt section that injects a profile's free-form persona blurb. +//! Prompt section that injects a profile's free-form persona blurb, plus the +//! optional cross-profile workspace notice (1b) that tells the model where its +//! dedicated workspace is and that other profiles' directories are off-limits. + +use std::path::Path; use crate::openhuman::context::prompt::{PromptContext, PromptSection}; use anyhow::Result; +/// One-sentence system-prompt notice, mirroring hermes's cross-profile +/// disclosure: names the profile's dedicated workspace and states that other +/// profiles' directories are off-limits. Rendered only when a dedicated +/// workspace is active (the enforcement backstop is the guard in +/// [`crate::openhuman::profiles::guard`]). +pub fn cross_profile_workspace_notice(profile_id: &str, workspace_path: &Path) -> String { + format!( + "Your dedicated workspace for profile `{profile_id}` is `{}`. Work only there; the \ + directories of other profiles are off-limits.", + workspace_path.display() + ) +} + /// Renders a profile's `system_prompt_suffix` (or any free-form persona body) -/// as a `## Agent profile` block in the system prompt. +/// as a `## Agent profile` block in the system prompt, optionally followed by +/// the cross-profile workspace notice. pub struct AgentProfilePromptSection { body: String, + workspace_notice: Option, } impl AgentProfilePromptSection { pub fn new(body: String) -> Self { - Self { body } + Self { + body, + workspace_notice: None, + } + } + + /// Attach the cross-profile workspace notice (1b). Rendered under the + /// persona body — or on its own when the body is empty — so a + /// dedicated-workspace profile always discloses its boundary even without a + /// custom persona suffix. + #[must_use] + pub fn with_workspace_notice(mut self, notice: String) -> Self { + let notice = notice.trim().to_string(); + self.workspace_notice = (!notice.is_empty()).then_some(notice); + self } } @@ -21,10 +54,19 @@ impl PromptSection for AgentProfilePromptSection { } fn build(&self, _ctx: &PromptContext<'_>) -> Result { - if self.body.trim().is_empty() { + let body = self.body.trim(); + let notice = self.workspace_notice.as_deref().unwrap_or_default(); + let mut parts: Vec<&str> = Vec::new(); + if !body.is_empty() { + parts.push(body); + } + if !notice.is_empty() { + parts.push(notice); + } + if parts.is_empty() { return Ok(String::new()); } - Ok(format!("## Agent profile\n\n{}", self.body.trim())) + Ok(format!("## Agent profile\n\n{}", parts.join("\n\n"))) } } @@ -68,4 +110,66 @@ mod tests { let empty = AgentProfilePromptSection::new(" ".into()); assert_eq!(empty.build(&ctx).expect("empty profile section"), ""); } + + fn empty_ctx<'a>(visible: &'a HashSet) -> PromptContext<'a> { + PromptContext { + workspace_dir: std::path::Path::new("/tmp"), + model_name: "test-model", + agent_id: "orchestrator", + tools: &[], + workflows: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, + } + } + + #[test] + fn cross_profile_workspace_notice_names_path_and_boundary() { + let notice = + cross_profile_workspace_notice("alice", std::path::Path::new("/act/profiles/alice")); + assert!(notice.contains("alice")); + assert!(notice.contains("/act/profiles/alice")); + assert!(notice.contains("off-limits")); + } + + #[test] + fn workspace_notice_renders_with_and_without_body() { + let visible = HashSet::new(); + let ctx = empty_ctx(&visible); + + // Notice-only (no persona body) still emits the block + the notice. + let notice_only = AgentProfilePromptSection::new(String::new()) + .with_workspace_notice("boundary sentence.".into()); + let rendered = notice_only.build(&ctx).expect("render notice-only"); + assert!(rendered.starts_with("## Agent profile")); + assert!(rendered.contains("boundary sentence.")); + + // Body + notice: both present, body first. + let both = AgentProfilePromptSection::new("Be terse.".into()) + .with_workspace_notice("boundary sentence.".into()); + let rendered = both.build(&ctx).expect("render both"); + let body_at = rendered.find("Be terse.").expect("body present"); + let notice_at = rendered.find("boundary sentence.").expect("notice present"); + assert!(body_at < notice_at, "persona body must precede the notice"); + + // A blank notice is dropped, leaving only the body. + let blank_notice = + AgentProfilePromptSection::new("Be terse.".into()).with_workspace_notice(" ".into()); + let rendered = blank_notice.build(&ctx).expect("render blank notice"); + assert!(rendered.contains("Be terse.")); + assert!(!rendered.contains("off-limits")); + } } diff --git a/src/openhuman/profiles/schemas.rs b/src/openhuman/profiles/schemas.rs index 5b7ed0f631..09e1ae3161 100644 --- a/src/openhuman/profiles/schemas.rs +++ b/src/openhuman/profiles/schemas.rs @@ -46,7 +46,10 @@ pub fn schemas(function: &str) -> ControllerSchema { "list" => ControllerSchema { namespace: "profiles", function: "list", - description: "List persistent agent profiles and the active profile id.", + description: "List persistent agent profiles and the active profile id. Each \ + profile is enriched with resolved read-only path info: soulMdFile \ + (personalities//SOUL.md if present) and workspaceDir (the \ + dedicated workspace when opted in).", inputs: vec![], outputs: vec![json_output("profiles", "Agent profile state payload.")], }, @@ -65,8 +68,9 @@ pub fn schemas(function: &str) -> ControllerSchema { function: "upsert", description: "Create or update an agent profile. The `profile` payload may include \ memory_sources, includeAgentConversations, allowedSkills, \ - allowedMcpServers, composioIntegrations, allowedTools, and soulMd; \ - an omitted/empty allowlist means \"all\".", + allowedMcpServers, composioIntegrations, allowedTools, soulMd, \ + dedicatedMemory (own memory subtree), and dedicatedWorkspace (own \ + working dir under action_dir); an omitted/empty allowlist means \"all\".", inputs: vec![FieldSchema { name: "profile", ty: TypeSchema::Json, @@ -273,6 +277,100 @@ mod tests { assert_eq!(deleted["activeProfileId"], DEFAULT_PROFILE_ID); } + /// Resolve the enriched `soulMdFile` absolute path for `profile_id` from a + /// profiles-state payload (present once the home's SOUL.md exists on disk). + fn soul_md_file(payload: &Value, profile_id: &str) -> Option { + payload["profiles"] + .as_array()? + .iter() + .find(|p| p["id"] == profile_id)? + .get("soulMdFile")? + .as_str() + .map(str::to_string) + } + + #[tokio::test] + async fn built_in_profile_soul_edit_syncs_to_disk() { + // Regression (PR #5118 review, Codex): select() seeds a built-in's home on + // first activation, so a later Soul edit through the editor must reconcile + // the on-disk SOUL.md — the sync path must NOT be gated on `!built_in`. + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + + const PROFILE_ID: &str = "reasoning"; + // Seed a non-default built-in home the way first activation does; the + // unedited Default intentionally keeps using the legacy root SOUL.md. + // enriched payload advertises the resolved SOUL.md path once it exists. + let selected = handle_profile_select(Map::from_iter([( + "profile_id".into(), + Value::String(PROFILE_ID.into()), + )])) + .await + .expect("select built-in"); + let soul_path = soul_md_file(&selected, PROFILE_ID).expect("select seeds built-in SOUL.md"); + + // User later edits the built-in's Soul in Settings. + handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": PROFILE_ID, + "name": "Reasoning", + "description": "", + "agentId": "orchestrator", + "soulMd": "Edited built-in persona.", + "builtIn": true, + }), + )])) + .await + .expect("upsert default with edited soul"); + + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Edited built-in persona.\n", + "editing a built-in's soul must overwrite its seeded SOUL.md" + ); + } + + #[tokio::test] + async fn built_in_profile_empty_soul_leaves_file_untouched() { + // The sync is a no-op when soulMd is empty/None, so a user's manual edit + // to a built-in's SOUL.md stays authoritative. + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + + const PROFILE_ID: &str = "reasoning"; + let selected = handle_profile_select(Map::from_iter([( + "profile_id".into(), + Value::String(PROFILE_ID.into()), + )])) + .await + .expect("select built-in"); + let soul_path = soul_md_file(&selected, PROFILE_ID).expect("select seeds built-in SOUL.md"); + std::fs::write(&soul_path, "MANUAL EDIT").unwrap(); + + // Upsert with no soulMd — must not touch the manually edited file. + handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": PROFILE_ID, + "name": "Reasoning", + "description": "", + "agentId": "orchestrator", + "builtIn": true, + }), + )])) + .await + .expect("upsert default without soul"); + + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "MANUAL EDIT", + "empty soulMd must leave a manually edited built-in SOUL.md untouched" + ); + } + #[tokio::test] async fn profile_upsert_rejects_unknown_registered_agent_id() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/openhuman/profiles/store.rs b/src/openhuman/profiles/store.rs index ad391e9743..3ed640a48d 100644 --- a/src/openhuman/profiles/store.rs +++ b/src/openhuman/profiles/store.rs @@ -148,6 +148,7 @@ impl AgentProfileStore { pub fn upsert(&self, profile: AgentProfile) -> Result { let mut state = self.load()?; let profile = normalise_profile(profile); + super::home::validate_profile_id(&profile.id)?; tracing::debug!( profile_id = %profile.id, agent_id = %profile.agent_id, @@ -171,6 +172,8 @@ impl AgentProfileStore { default.include_agent_conversations = profile.include_agent_conversations; default.allowed_skills = profile.allowed_skills; default.allowed_mcp_servers = profile.allowed_mcp_servers; + default.dedicated_memory = profile.dedicated_memory; + default.dedicated_workspace = profile.dedicated_workspace; // memory_dir_suffix stays as built-in default (don't let user override the default's suffix) default.sort_order = profile.sort_order; default @@ -332,6 +335,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "research".to_string(), @@ -358,6 +363,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "planner".to_string(), @@ -384,6 +391,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "review".to_string(), @@ -410,6 +419,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, ] } @@ -437,6 +448,8 @@ pub(crate) fn built_in_default_profile() -> AgentProfile { memory_dir_suffix: Some("".into()), is_master: true, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -575,6 +588,14 @@ fn next_available_suffix(existing: &std::collections::HashSet) -> String } } +/// Normalise a raw profile id into its persisted slug form, mirroring the +/// transformation `normalise_profile` applies on upsert. Exposed so callers +/// (e.g. `ops::upsert`) can locate the persisted profile by its stored id after +/// the store has slugified it. +pub(crate) fn normalise_profile_id(input: &str) -> String { + slugify_profile_id(input) +} + fn slugify_profile_id(input: &str) -> String { let mut out = String::new(); let mut last_was_sep = false; @@ -629,6 +650,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -664,6 +687,24 @@ mod tests { assert_eq!(resolved.id, "custom-profile"); } + #[test] + fn upsert_rejects_profile_ids_longer_than_home_path_limit() { + let dir = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(dir.path().to_path_buf()); + let profile = custom(&"a".repeat(65), "Overlong", "orchestrator"); + + let error = store + .upsert(profile) + .expect_err("overlong id must be rejected"); + assert!(error.contains("too long")); + assert!(store + .load() + .expect("load") + .profiles + .iter() + .all(|profile| profile.name != "Overlong")); + } + #[test] fn built_in_profiles_are_merged_when_file_is_missing() { let dir = tempdir().expect("tempdir"); @@ -729,6 +770,8 @@ mod tests { profile.system_prompt_suffix = Some(" suffix ".into()); profile.allowed_tools = Some(vec![" todo ".into()]); profile.memory_sources = Some(vec!["slack-eng".into()]); + profile.dedicated_memory = true; + profile.dedicated_workspace = true; let state = store.upsert(profile).expect("upsert default"); let default = state .profiles @@ -744,6 +787,8 @@ mod tests { default.memory_sources.as_deref(), Some(vec!["slack-eng".to_string()].as_slice()) ); + assert!(default.dedicated_memory); + assert!(default.dedicated_workspace); } #[test] @@ -823,6 +868,25 @@ mod tests { assert_eq!(charlie.memory_dir_suffix.as_deref(), Some("-1")); } + #[test] + fn upsert_roundtrips_dedicated_home_fields() { + let dir = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(dir.path().to_path_buf()); + let mut profile = custom("iso", "Iso", "orchestrator"); + profile.dedicated_memory = true; + profile.dedicated_workspace = true; + store.upsert(profile).expect("upsert"); + + let loaded = store.load().expect("load"); + let iso = loaded + .profiles + .iter() + .find(|p| p.id == "iso") + .expect("iso profile"); + assert!(iso.dedicated_memory); + assert!(iso.dedicated_workspace); + } + #[test] fn default_profile_has_master_and_memory_suffix() { let default = built_in_default_profile(); diff --git a/src/openhuman/profiles/types.rs b/src/openhuman/profiles/types.rs index 319faf9b53..a335c10be2 100644 --- a/src/openhuman/profiles/types.rs +++ b/src/openhuman/profiles/types.rs @@ -66,6 +66,16 @@ pub struct AgentProfile { /// Display order (lower = shown first). #[serde(default, skip_serializing_if = "Option::is_none")] pub sort_order: Option, + /// Give this profile its own memory subtree (`memory-`, + /// `memory_tree-`, `session_raw-`) instead of the shared/global one. + /// Default false. See [`super::paths::effective_memory_suffix`]. + #[serde(default)] + pub dedicated_memory: bool, + /// Give this profile its own working directory under `action_dir` + /// (`/profiles/`) used as the default cwd for its tool runs. + /// Default false. See [`super::home::dedicated_workspace_dir`]. + #[serde(default)] + pub dedicated_workspace: bool, } /// serde default for `include_agent_conversations` (true). @@ -115,6 +125,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }; assert!(profile_signature(&profile).contains("\"planner\"")); } @@ -144,5 +156,28 @@ mod tests { // Defaults to true so existing users keep cross-chat recall. assert!(profile.include_agent_conversations); assert!(!profile.is_master); + // New home fields default to false so legacy payloads keep behaving. + assert!(!profile.dedicated_memory); + assert!(!profile.dedicated_workspace); + } + + #[test] + fn new_home_fields_roundtrip_over_camelcase() { + let json = json!({ + "id": "alice", + "name": "Alice", + "description": "", + "agentId": "orchestrator", + "builtIn": false, + "dedicatedMemory": true, + "dedicatedWorkspace": true + }); + let profile: AgentProfile = serde_json::from_value(json).expect("deserialize"); + assert!(profile.dedicated_memory); + assert!(profile.dedicated_workspace); + // Serializes back out as camelCase. + let out = serde_json::to_value(&profile).expect("serialize"); + assert_eq!(out["dedicatedMemory"], serde_json::json!(true)); + assert_eq!(out["dedicatedWorkspace"], serde_json::json!(true)); } } diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs index d82091fc0a..d819dc9426 100644 --- a/src/openhuman/runtime_node/ops.rs +++ b/src/openhuman/runtime_node/ops.rs @@ -127,6 +127,9 @@ pub fn build_runtime_tools(config: &Config) -> Result>, String config, None, None, + None, + None, + None, ); debug!( tool_count = built.len(), diff --git a/src/openhuman/security/policy/enforcement.rs b/src/openhuman/security/policy/enforcement.rs index 6e18944960..cf1f5fe907 100644 --- a/src/openhuman/security/policy/enforcement.rs +++ b/src/openhuman/security/policy/enforcement.rs @@ -168,9 +168,40 @@ impl SecurityPolicy { auto_approve_all: autonomy_config.auto_approve_all, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), + // No active profile by default — armed explicitly by the session + // builder via [`with_active_profile`] when any profile is in play. + // Keeps every existing profile-less `from_config` caller on + // the byte-identical, guard-off path. + active_profile: None, } } + /// Return a copy of this policy with the cross-profile write guard armed for + /// `profile_id`, whose sibling workspaces live under + /// `/profiles//` (1b). + /// + /// Builder-style so the session builder reads as + /// `SecurityPolicy::from_config(..).with_active_profile(id, action_dir)`. + /// Every profiled session calls this; a profile-less session never does, so + /// the guard stays dormant and path validation is unchanged. + #[must_use] + pub fn with_active_profile( + mut self, + profile_id: String, + action_dir: std::path::PathBuf, + ) -> Self { + tracing::debug!( + profile_id = %profile_id, + action_dir = %action_dir.display(), + "[profiles][security] cross-profile write guard armed for session" + ); + self.active_profile = Some(super::types::ActiveProfileGuard { + profile_id, + action_dir, + }); + self + } + /// Return a copy of this policy with `privacy_mode` set. Used by the live- /// policy install / reload chokepoints to layer `config.privacy.mode` onto a /// policy that [`from_config`](Self::from_config) built with the `Standard` diff --git a/src/openhuman/security/policy/mod.rs b/src/openhuman/security/policy/mod.rs index a93791d83b..b4d4d155ed 100644 --- a/src/openhuman/security/policy/mod.rs +++ b/src/openhuman/security/policy/mod.rs @@ -10,8 +10,9 @@ mod types; pub use enforcement::validate_path_within_root; pub use enforcement::{ensure_openhuman_scratch_dir, openhuman_scratch_dir}; pub use types::{ - ActionTracker, AutonomyLevel, CommandClass, CommandRiskLevel, GateDecision, SecurityPolicy, - ToolOperation, TrustedAccess, TrustedRoot, POLICY_BLOCKED_MARKER, POLICY_DENIED_MARKER, + ActionTracker, ActiveProfileGuard, AutonomyLevel, CommandClass, CommandRiskLevel, GateDecision, + SecurityPolicy, ToolOperation, TrustedAccess, TrustedRoot, POLICY_BLOCKED_MARKER, + POLICY_DENIED_MARKER, }; #[cfg(test)] diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index fb930b05d2..32a56b5b58 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -38,6 +38,22 @@ impl SecurityPolicy { let expanded = self.expand_tilde(path); let expanded_path = Path::new(&expanded); + // Core-sensitive single-file names remain reserved even when a + // relative request resolves under the separate action root. Directory + // names are root-sensitive (a project may legitimately have + // `personalities/`), but `.env`/SOUL.md/etc. retain the existing + // top-level deny contract. + if !expanded_path.is_absolute() + && expanded_path.components().count() == 1 + && WORKSPACE_INTERNAL_FILES.iter().any(|name| { + expanded_path + .file_name() + .is_some_and(|requested| requested == std::ffi::OsStr::new(name)) + }) + { + return false; + } + // Credential stores are never reachable, even via a trusted-root grant. if Self::is_always_forbidden(expanded_path) { return false; @@ -48,22 +64,25 @@ impl SecurityPolicy { // operation-specific validators (validate_path / validate_parent_path). let in_trusted_root = self.is_within_trusted_root(expanded_path, false); - // Block agent access to internal state paths under workspace_dir - // (unless the path falls under an explicitly granted trusted root). - if !in_trusted_root { - let check = if expanded_path.is_absolute() { - expanded_path.to_path_buf() - } else { - self.workspace_dir.join(expanded_path) - }; - if self.is_workspace_internal_path(&check) { - log::trace!( - "[security:policy] path blocked: agent access to workspace-internal state (requested={}, resolved={})", - path, - check.display() - ); - return false; - } + // Workspace-internal application state is never agent-accessible. A + // trusted-root grant cannot weaken this invariant, even when it points + // at workspace_dir or one of its parents. + let check = if expanded_path.is_absolute() { + expanded_path.to_path_buf() + } else { + // File tools resolve relative paths from action_dir, not the + // core-state workspace. Joining workspace_dir here accidentally + // reserves internal directory names (for example + // `personalities/alice.md`) in an otherwise legitimate project. + self.action_dir.join(expanded_path) + }; + if self.is_workspace_internal_path(&check) { + log::trace!( + "[security:policy] path blocked: agent access to workspace-internal state (requested={}, resolved={})", + path, + check.display() + ); + return false; } // Block absolute paths when workspace_only is set (unless trusted-rooted). @@ -224,6 +243,7 @@ impl SecurityPolicy { } let workspace_root = self.workspace_root().await; self.check_resolved_against_forbidden(&resolved, &workspace_root)?; + self.check_cross_profile(&resolved)?; log::debug!( "[security] validate_path: '{}' resolved to '{}'", path, @@ -292,6 +312,7 @@ impl SecurityPolicy { let workspace_root = self.workspace_root().await; self.check_resolved_against_forbidden(&canonical_ancestor, &workspace_root)?; self.check_resolved_against_forbidden(&result, &workspace_root)?; + self.check_cross_profile(&result)?; log::debug!( "[security] validate_parent_path: '{}' resolved parent to '{}'", @@ -301,6 +322,50 @@ impl SecurityPolicy { Ok(result) } + /// Cross-profile write guard (1b). A no-op unless the session runs under an + /// active profile (`active_profile` armed via + /// [`SecurityPolicy::with_active_profile`]). When armed, a resolved target + /// that lands inside a *sibling* profile's workspace + /// (`/profiles//`, `Q != active`) is refused with the + /// permanent `[policy-blocked]` marker so the harness halts instead of + /// retrying. This only ever tightens: `None` leaves every path check + /// byte-identical, and it never loosens `is_workspace_internal_path` or any + /// other `SecurityPolicy` check. + pub(super) fn check_cross_profile(&self, resolved: &Path) -> Result<(), String> { + let Some(guard) = self.active_profile.as_ref() else { + return Ok(()); + }; + if let crate::openhuman::profiles::CrossProfileDecision::Block { other_id } = + crate::openhuman::profiles::classify_cross_profile_target( + &guard.action_dir, + &guard.profile_id, + resolved, + ) + { + tracing::warn!( + active_profile = %guard.profile_id, + other_profile = %other_id, + target = %resolved.display(), + "[profiles] cross-profile write blocked" + ); + if other_id == crate::openhuman::profiles::PROFILES_ROOT_SENTINEL { + return Err(format!( + "{POLICY_BLOCKED_MARKER} Cross-profile access blocked: profile '{}' may not \ + write to the shared profiles root. Stay within your own profile directory; \ + do not retry this path.", + guard.profile_id + )); + } + return Err(format!( + "{POLICY_BLOCKED_MARKER} Cross-profile access blocked: profile '{}' may not write \ + into profile '{}'s workspace. Stay within your own profile directory; do not \ + retry this path.", + guard.profile_id, other_id + )); + } + Ok(()) + } + /// Returns `true` if `path` falls under one of the internal-state /// subdirectories or files within `workspace_dir`. Agent tools must not /// write to these locations — they contain memory DBs, session transcripts, @@ -326,9 +391,15 @@ impl SecurityPolicy { Some(std::path::Component::Normal(s)) => s.to_string_lossy(), _ => return false, }; - if WORKSPACE_INTERNAL_DIRS - .iter() - .any(|d| *d == first_component.as_ref()) + let component = first_component.as_ref(); + if WORKSPACE_INTERNAL_DIRS.contains(&component) + || ["memory-", "memory_tree-", "session_raw-"] + .iter() + .any(|prefix| { + component + .strip_prefix(prefix) + .is_some_and(|s| !s.is_empty()) + }) { return true; } @@ -454,6 +525,14 @@ impl SecurityPolicy { resolved.display() )); } + // Trusted roots may override user-configured forbidden paths, but never + // the core-managed workspace-state boundary. + if self.is_workspace_internal_path(resolved) { + return Err(format!( + "{POLICY_BLOCKED_MARKER} Resolved path is workspace-internal application state: {}", + resolved.display() + )); + } // A trusted-root grant takes precedence over forbidden_paths for its subtree. if self.is_within_trusted_root(resolved, false) { return Ok(()); diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index 1978eee17c..ce0992d564 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -18,6 +18,80 @@ fn full_policy() -> SecurityPolicy { } } +// -- Cross-profile write guard (1b) ------------------------------- +// +// These drive the guard through the real `validate_parent_path` gate that every +// file write tool funnels through, proving the tightening lands at the shared +// call site (not just in the standalone classifier). `active_profile = None` +// keeps the exact same setup passing, pinning the byte-identical shared path. + +/// Build a `/projects/profiles/{alice,bob}` layout and a policy whose cwd +/// is scoped to alice (as `security_for_tool_context` would), with the guard +/// optionally armed for alice against the broad action root. +fn cross_profile_policy(arm_for_alice: bool) -> (tempfile::TempDir, PathBuf, SecurityPolicy) { + let root = tempfile::tempdir().expect("root tempdir"); + let action_root = root.path().join("projects"); + let profiles = action_root.join("profiles"); + for id in ["alice", "bob"] { + std::fs::create_dir_all(profiles.join(id)).unwrap(); + } + let alice_dir = profiles.join("alice"); + let policy = SecurityPolicy { + autonomy: AutonomyLevel::Full, + // Everything under `root` is inside the workspace, so the sibling path + // clears containment and reaches the cross-profile check. + workspace_dir: root.path().to_path_buf(), + // Mirrors the per-tool-call override: cwd scoped to the profile dir. + action_dir: alice_dir.clone(), + workspace_only: false, + // Clear the default forbidden list (it blocks /tmp, /var, …, which the + // OS tempdir lives under) so the guard is what does the blocking. + forbidden_paths: Vec::new(), + active_profile: arm_for_alice.then(|| ActiveProfileGuard { + profile_id: "alice".to_string(), + action_dir: action_root.clone(), + }), + ..SecurityPolicy::default() + }; + (root, action_root, policy) +} + +#[tokio::test] +async fn cross_profile_guard_blocks_write_into_sibling_profile() { + let (_root, action_root, policy) = cross_profile_policy(true); + let sibling = action_root.join("profiles").join("bob").join("loot.txt"); + let err = policy + .validate_parent_path(sibling.to_str().unwrap()) + .await + .expect_err("write into sibling profile must be blocked"); + assert!(err.contains(POLICY_BLOCKED_MARKER), "err: {err}"); + assert!(err.contains("bob"), "error should name the sibling: {err}"); +} + +#[tokio::test] +async fn cross_profile_guard_allows_write_into_own_profile() { + let (_root, action_root, policy) = cross_profile_policy(true); + let own = action_root.join("profiles").join("alice").join("notes.txt"); + let resolved = policy + .validate_parent_path(own.to_str().unwrap()) + .await + .expect("write into own profile must be allowed"); + assert!(resolved.ends_with("notes.txt")); +} + +#[tokio::test] +async fn cross_profile_guard_disarmed_allows_sibling_write() { + // Same setup, guard OFF (active_profile None): the sibling write is allowed, + // proving the guard only tightens and the shared path is byte-identical. + let (_root, action_root, policy) = cross_profile_policy(false); + let sibling = action_root.join("profiles").join("bob").join("loot.txt"); + let resolved = policy + .validate_parent_path(sibling.to_str().unwrap()) + .await + .expect("with the guard disarmed the sibling write must be allowed"); + assert!(resolved.ends_with("loot.txt")); +} + // -- AutonomyLevel ------------------------------------------------ #[test] @@ -829,6 +903,30 @@ fn relative_paths_allowed() { assert!(p.is_path_string_allowed("deep/nested/dir/file.txt")); } +#[test] +fn relative_personalities_path_resolves_under_action_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("state"); + let action = tmp.path().join("projects"); + std::fs::create_dir_all(workspace.join("personalities").join("alice")).unwrap(); + std::fs::create_dir_all(action.join("personalities")).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + action_dir: action, + ..SecurityPolicy::default() + }; + + assert!(policy.is_path_string_allowed("personalities/alice.md")); + assert!(!policy.is_path_string_allowed( + workspace + .join("personalities") + .join("alice") + .join("SOUL.md") + .to_string_lossy() + .as_ref() + )); +} + #[test] fn path_traversal_blocked() { let p = default_policy(); @@ -2561,10 +2659,26 @@ fn is_workspace_internal_path_blocks_state_dirs() { }; assert!(policy.is_workspace_internal_path(&ws.join("memory"))); assert!(policy.is_workspace_internal_path(&ws.join("memory").join("namespaces"))); + assert!(policy.is_workspace_internal_path(&ws.join("memory-alice").join("memory.db"))); + assert!(policy.is_workspace_internal_path(&ws.join("memory_tree-alice").join("tree"))); + assert!(policy.is_workspace_internal_path( + &ws.join("session_raw-alice") + .join("1700000000_orchestrator.jsonl") + )); assert!(policy.is_workspace_internal_path(&ws.join("sessions"))); assert!(policy.is_workspace_internal_path(&ws.join("state"))); assert!(policy.is_workspace_internal_path(&ws.join("cron"))); assert!(policy.is_workspace_internal_path(&ws.join("memory_tree"))); + assert!( + policy.is_workspace_internal_path(&ws.join("personalities").join("alice").join("SOUL.md")) + ); + assert!(policy.is_workspace_internal_path( + &ws.join("personalities") + .join("alice") + .join("skills") + .join("private-skill") + .join("SKILL.md") + )); assert!(policy.is_workspace_internal_path(&ws.join("approval"))); assert!(policy.is_workspace_internal_path(&ws.join("mcp_clients"))); } @@ -2618,6 +2732,37 @@ fn is_path_string_allowed_blocks_workspace_internal() { ); } +#[tokio::test] +async fn trusted_root_cannot_expose_workspace_internal_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ws = tmp.path().join("workspace"); + let personality = ws.join("personalities").join("alice"); + std::fs::create_dir_all(&personality).expect("create profile home"); + let soul = personality.join("SOUL.md"); + std::fs::write(&soul, "private identity").expect("write soul"); + let policy = SecurityPolicy { + workspace_dir: ws.clone(), + action_dir: ws.clone(), + workspace_only: false, + trusted_roots: vec![TrustedRoot { + path: ws.to_string_lossy().into_owned(), + access: TrustedAccess::ReadWrite, + }], + ..SecurityPolicy::default() + }; + + assert!(!policy.is_path_string_allowed(&soul.to_string_lossy())); + assert!(policy.validate_path(&soul.to_string_lossy()).await.is_err()); + assert!(policy + .validate_parent_path( + &ws.join("session_raw-alice") + .join("new.jsonl") + .to_string_lossy() + ) + .await + .is_err()); +} + #[test] fn action_dir_in_default_policy() { let policy = SecurityPolicy::default(); diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index a3a683ebd9..36babadc8b 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -51,7 +51,9 @@ pub enum TrustedAccess { /// A directory outside the workspace the agent is explicitly granted access to. /// Takes precedence over `workspace_only` and `forbidden_paths` for its subtree, -/// except for credential stores (see `SecurityPolicy::is_always_forbidden`). +/// except for credential stores and workspace-internal application state (see +/// `SecurityPolicy::is_always_forbidden` and +/// `SecurityPolicy::is_workspace_internal_path`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct TrustedRoot { /// Absolute path (a leading `~` is expanded to the user's home). @@ -177,6 +179,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "approval", "sessions", "session_raw", + // Per-profile homes contain prompt-controlling SOUL.md files and private + // skills. They are core-managed state, never part of the agent action + // surface, even when a trusted root otherwise reaches workspace_dir. + "personalities", "cron", "devices", "mcp_clients", @@ -206,6 +212,24 @@ pub(super) const WORKSPACE_INTERNAL_FILES: &[&str] = &[ "PROFILE.md", ]; +/// The active agent-profile context that arms the cross-profile write guard +/// (1b). Present **only** when a turn runs under a dedicated-workspace profile; +/// `None` (the common case) leaves every path check byte-identical to today. +/// +/// Carries the broad `action_dir` deliberately: `security_for_tool_context` +/// clones the policy and overrides its `action_dir` to the profile's own dir +/// for relative-path resolution, so the guard cannot derive the profiles root +/// from `SecurityPolicy::action_dir` at check time — it reads the immutable +/// `action_dir` captured here instead. +#[derive(Debug, Clone)] +pub struct ActiveProfileGuard { + /// Id of the profile the turn runs under (`P`). + pub profile_id: String, + /// The agent's broad action root; sibling profile workspaces live under + /// `/profiles//`. + pub action_dir: PathBuf, +} + /// Security policy enforced on all tool executions #[derive(Debug, Clone)] pub struct SecurityPolicy { @@ -281,6 +305,12 @@ pub struct SecurityPolicy { /// default. `pub(crate)` was an over-tight first cut that broke /// `examples/mouse_smoke.rs` with E0451. pub canonical_workspace: Arc>, + /// Arms the cross-profile write guard (1b) when a dedicated-workspace + /// profile is active. `None` (default) = no active profile → the guard is + /// never consulted and path validation is byte-identical to today. Set once + /// at session build; carried through the per-tool-call clone made by + /// `security_for_tool_context` so file tools see it too. + pub active_profile: Option, } impl Default for SecurityPolicy { @@ -381,6 +411,7 @@ impl Default for SecurityPolicy { auto_approve_all: false, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), + active_profile: None, } } } diff --git a/src/openhuman/skill_runtime/mod.rs b/src/openhuman/skill_runtime/mod.rs index 4d0dfedb5a..6143eaea8a 100644 --- a/src/openhuman/skill_runtime/mod.rs +++ b/src/openhuman/skill_runtime/mod.rs @@ -27,7 +27,10 @@ pub mod schemas; pub mod tools; #[cfg(feature = "skills")] -pub use run_machinery::{await_run_outcome, spawn_workflow_run_background, WorkflowRunStarted}; +pub use run_machinery::{ + await_run_outcome, spawn_workflow_run_background, spawn_workflow_run_background_with_profile, + WorkflowRunStarted, +}; #[cfg(feature = "skills")] pub use schemas::{ all_skill_runtime_controller_schemas, all_skill_runtime_registered_controllers, diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index f69bba0f1b..6f238f3ff8 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -14,6 +14,20 @@ use crate::openhuman::skills::{preflight, registry, run_log}; use crate::openhuman::skills::schemas::resolve_workspace_dir; +async fn with_profile_memory_source_scope( + active_profile: Option<&crate::openhuman::profiles::AgentProfile>, + fut: F, +) -> T +where + F: std::future::Future, +{ + crate::openhuman::memory::source_scope::with_source_scope( + active_profile.and_then(|profile| profile.memory_sources.clone()), + fut, + ) + .await +} + /// Iteration cap for an autonomous skill run (orchestrator + sub-agents). High /// enough to "run until done", while the repeated-failure circuit breaker still /// stops dead-end grinding — deliberately bounded (not infinite) to cap spend. @@ -41,10 +55,30 @@ pub struct WorkflowRunStarted { pub async fn spawn_workflow_run_background( skill_id_param: String, inputs_param: Option, +) -> Result { + spawn_workflow_run_background_with_profile(skill_id_param, inputs_param, None, None).await +} + +/// Like [`spawn_workflow_run_background`], but resolves the target skill against +/// the active profile's private skills root too +/// (`/personalities//skills/`) when `profile_skills_root` is +/// supplied — so `run_workflow` under profile P can run P's private skills, with +/// profile-local winning same-name collisions. `None` is byte-identical to +/// [`spawn_workflow_run_background`]. The root is passed by value (owned) so it +/// can cross the resolution boundary without borrowing across the spawn. +pub async fn spawn_workflow_run_background_with_profile( + skill_id_param: String, + inputs_param: Option, + profile_skills_root: Option, + active_profile: Option, ) -> Result { let workspace = resolve_workspace_dir().await; - let skill = registry::get_workflow(&workspace, &skill_id_param) - .ok_or_else(|| format!("workflow_run: unknown skill '{skill_id_param}'"))?; + let skill = registry::get_workflow_with_profile( + &workspace, + &skill_id_param, + profile_skills_root.as_deref(), + ) + .ok_or_else(|| format!("workflow_run: unknown skill '{skill_id_param}'"))?; let inputs = inputs_param.unwrap_or(Value::Null); let missing = registry::missing_required_inputs(&skill.inputs, &inputs); if !missing.is_empty() { @@ -87,12 +121,13 @@ pub async fn spawn_workflow_run_background( gate decision: FAILED ({tag})\n\ detail: {body}" ); - if let Err(e) = run_log::write_header( + if let Err(e) = run_log::write_header_with_profile( &gate_log_path, &skill.definition.id, &gate_run_id, &inputs, &header_prompt, + active_profile.as_ref().map(|profile| profile.id.as_str()), ) .await { @@ -157,9 +192,18 @@ pub async fn spawn_workflow_run_background( let inputs = inputs.clone(); let log_path = log_path.clone(); let inherited_origin = inherited_origin.clone(); + let active_profile = active_profile.clone(); + let run_profile_id = active_profile.as_ref().map(|profile| profile.id.clone()); tokio::spawn(async move { - if let Err(e) = - run_log::write_header(&log_path, &workflow_id, &run_id, &inputs, &task_prompt).await + if let Err(e) = run_log::write_header_with_profile( + &log_path, + &workflow_id, + &run_id, + &inputs, + &task_prompt, + run_profile_id.as_deref(), + ) + .await { tracing::warn!(run_id = %run_id, error = %e, "[skills] workflow_run: header write failed"); } @@ -182,7 +226,15 @@ pub async fn spawn_workflow_run_background( if config.http_request.allowed_domains.is_empty() { config.http_request.allowed_domains = vec!["*".to_string()]; } - let mut agent = match Agent::from_config_for_agent(&config, "orchestrator") { + let mut agent = match Agent::from_config_for_agent_with_profile( + &config, + "orchestrator", + None, + active_profile + .as_ref() + .and_then(|profile| profile.system_prompt_suffix.clone()), + active_profile.as_ref(), + ) { Ok(a) => a, Err(e) => { let _ = run_log::write_footer( @@ -235,11 +287,14 @@ pub async fn spawn_workflow_run_background( let result = tokio::select! { biased; _ = cancel_token.cancelled() => None, - r = crate::openhuman::agent::turn_origin::with_origin( - inherited_origin, - with_autonomous_iter_cap( - WORKFLOW_RUN_MAX_ITERATIONS, - agent.run_single(&task_prompt), + r = with_profile_memory_source_scope( + active_profile.as_ref(), + crate::openhuman::agent::turn_origin::with_origin( + inherited_origin, + with_autonomous_iter_cap( + WORKFLOW_RUN_MAX_ITERATIONS, + agent.run_single(&task_prompt), + ), ), ) => Some(r), }; @@ -320,3 +375,32 @@ pub async fn await_run_outcome( tokio::time::sleep(POLL_INTERVAL.min(remaining)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn workflow_profile_installs_memory_source_scope() { + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.memory_sources = Some(vec!["slack:#eng".into(), "github:openhuman".into()]); + + let visible = with_profile_memory_source_scope(Some(&profile), async { + crate::openhuman::memory::source_scope::current_source_scope() + }) + .await; + + assert_eq!( + visible, + Some(std::collections::HashSet::from([ + "slack:#eng".into(), + "github:openhuman".into(), + ])) + ); + assert_eq!( + crate::openhuman::memory::source_scope::current_source_scope(), + None, + "workflow scope must not leak after the run future finishes" + ); + } +} diff --git a/src/openhuman/skills/ops.rs b/src/openhuman/skills/ops.rs index d6ad883101..28a7d09e07 100644 --- a/src/openhuman/skills/ops.rs +++ b/src/openhuman/skills/ops.rs @@ -31,8 +31,9 @@ // callers are unaffected. pub use super::ops_create::{create_workflow, CreateWorkflowParams, WorkflowCreateInputDef}; pub use super::ops_discover::{ - discover_automations, discover_workflows, init_workflows_dir, is_workspace_trusted, - load_workflow_metadata, read_workflow_resource, + discover_automations, discover_workflows, discover_workflows_with_profile, init_workflows_dir, + is_workspace_trusted, load_workflow_metadata, load_workflow_metadata_for_profile, + profile_local_skill_ids, read_workflow_resource, read_workflow_resource_with_profile, }; pub use super::ops_install::{ install_workflow_from_url, uninstall_workflow, validate_install_url, validate_resolved_host, diff --git a/src/openhuman/skills/ops_create.rs b/src/openhuman/skills/ops_create.rs index b271dff224..587116f12c 100644 --- a/src/openhuman/skills/ops_create.rs +++ b/src/openhuman/skills/ops_create.rs @@ -149,7 +149,10 @@ fn legacy_workflow_dir( workspace_dir.join(".openhuman").join("skills"), workspace_dir.join(".agents").join("skills"), ], - WorkflowScope::Legacy => return None, + // Profile-local skills are placed by hand under + // `/personalities//skills/`, never scaffolded through + // the create path; treat them like Legacy here (no create target). + WorkflowScope::Legacy | WorkflowScope::Profile => return None, }; for root in roots { let canonical_root = match std::fs::canonicalize(&root) { @@ -214,9 +217,10 @@ pub(crate) fn create_workflow_inner( } workspace_dir.join(".openhuman").join("workflows") } - WorkflowScope::Legacy => { + WorkflowScope::Legacy | WorkflowScope::Profile => { return Err( - "cannot create skill in legacy scope; choose 'user' or 'project'".to_string(), + "cannot create skill in legacy or profile scope; choose 'user' or 'project'" + .to_string(), ); } }; @@ -378,7 +382,7 @@ pub(crate) fn create_workflow_inner( ); let trusted = is_workspace_trusted(workspace_dir); - let created = discover_workflows_inner(home_dir, Some(workspace_dir), trusted) + let created = discover_workflows_inner(home_dir, Some(workspace_dir), None, trusted) .into_iter() .find(|s| s.name == slug) .ok_or_else(|| format!("created skill '{slug}' but failed to re-discover"))?; diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index 0038c1732c..8bc4b6940b 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -66,7 +66,30 @@ pub fn init_workflows_dir(workspace_dir: &Path) -> Result<(), String> { pub fn load_workflow_metadata(workspace_dir: &Path) -> Vec { let trusted = is_workspace_trusted(workspace_dir); let home = dirs::home_dir(); - discover_workflows_inner(home.as_deref(), Some(workspace_dir), trusted) + discover_workflows_inner(home.as_deref(), Some(workspace_dir), None, trusted) +} + +/// Like [`load_workflow_metadata`], but additionally scans a profile-local +/// skills root (`/personalities//skills/`) when one is supplied. +/// +/// Callers pass the active profile's root (resolved via +/// `profiles::profile_skills_root`) so the returned catalog carries that +/// profile's private skills. `None` reproduces [`load_workflow_metadata`] +/// byte-for-byte, so the profile-less session and every other profile are +/// unaffected. Profile-local skills win same-name collisions against global +/// scopes (see [`WorkflowScope::Profile`]). +pub fn load_workflow_metadata_for_profile( + workspace_dir: &Path, + profile_skills_root: Option<&Path>, +) -> Vec { + let trusted = is_workspace_trusted(workspace_dir); + let home = dirs::home_dir(); + discover_workflows_inner( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) } /// Discover skills from every supported location. @@ -84,7 +107,26 @@ pub fn discover_workflows( workspace_dir: Option<&Path>, trusted: bool, ) -> Vec { - discover_workflows_inner(home_dir, workspace_dir, trusted) + discover_workflows_inner(home_dir, workspace_dir, None, trusted) +} + +/// Discover skills including a profile-local root, for a turn running under a +/// specific agent profile. +/// +/// `profile_skills_root` is `/personalities//skills/` (resolved +/// via `profiles::profile_skills_root`, which validates the id). It is scanned +/// unconditionally — no trust marker is required, since the directory is +/// core-managed under `workspace_dir` — and its bundles win same-name collisions +/// against every global scope for this profile. `None` is identical to +/// [`discover_workflows`], so other profiles and the default session never see +/// these skills. +pub fn discover_workflows_with_profile( + home_dir: Option<&Path>, + workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, + trusted: bool, +) -> Vec { + discover_workflows_inner(home_dir, workspace_dir, profile_skills_root, trusted) } /// Whether the workspace has opted into loading project-scope skills. @@ -116,9 +158,16 @@ const WORKFLOW_ROOT_KINDS: &[RootKind] = &[RootKind::Workflow]; pub(crate) fn discover_workflows_inner( home_dir: Option<&Path>, workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, trusted: bool, ) -> Vec { - discover_filtered(home_dir, workspace_dir, trusted, ALL_ROOT_KINDS) + discover_filtered( + home_dir, + workspace_dir, + profile_skills_root, + trusted, + ALL_ROOT_KINDS, + ) } /// Discover only *automation* bundles — those under the `workflows/` roots — @@ -143,7 +192,7 @@ pub fn discover_automations( has_workspace = workspace_dir.is_some(), "[workflows] discover:automations:enter" ); - discover_filtered(home_dir, workspace_dir, trusted, WORKFLOW_ROOT_KINDS) + discover_filtered(home_dir, workspace_dir, None, trusted, WORKFLOW_ROOT_KINDS) } /// Shared discovery core. `kinds` selects which root categories to scan, @@ -152,6 +201,7 @@ pub fn discover_automations( fn discover_filtered( home_dir: Option<&Path>, workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, trusted: bool, kinds: &[RootKind], ) -> Vec { @@ -159,6 +209,7 @@ fn discover_filtered( trusted, has_home = home_dir.is_some(), has_workspace = workspace_dir.is_some(), + has_profile_root = profile_skills_root.is_some(), include_skills = kinds.contains(&RootKind::Skill), include_workflows = kinds.contains(&RootKind::Workflow), "[workflows] discover:enter" @@ -210,6 +261,33 @@ fn discover_filtered( } } + // Profile-local skills (`/personalities//skills/`) are a skill + // root scoped to the *active* profile: scanned last and at the highest + // precedence so a profile-local bundle wins any same-name collision against + // the global scopes for its owner (see [`precedence`]). Excluded from the + // automations-only view for the same reason as the legacy skill root. No + // trust marker is consulted — the directory is core-managed under + // `workspace_dir`, seeded by `ensure_profile_home`. + if let Some(profile_root) = profile_skills_root { + if kinds.contains(&RootKind::Skill) { + tracing::debug!( + root = %profile_root.display(), + scope = ?WorkflowScope::Profile, + "[profiles] discover:branch:profile-local skills" + ); + let before = by_name.len(); + absorb( + &mut by_name, + scan_root(profile_root, WorkflowScope::Profile), + ); + tracing::debug!( + names_before = before, + names_after = by_name.len(), + "[profiles] profile-local skills absorbed (profile scope wins same-name collisions)" + ); + } + } + let mut out: Vec = by_name.into_values().collect(); out.sort_by(|a, b| a.name.cmp(&b.name)); tracing::debug!(discovered_count = out.len(), "[workflows] discover:exit"); @@ -245,35 +323,63 @@ fn project_roots(workspace: &Path) -> Vec<(PathBuf, RootKind)> { fn absorb(by_name: &mut HashMap, incoming: Vec) { for mut skill in incoming { let key = skill.name.clone(); - if let Some(existing) = by_name.remove(&key) { - // Higher-precedence scope wins; lower loses and is dropped. - let (winner, loser) = if precedence(skill.scope) >= precedence(existing.scope) { - (&mut skill, existing) - } else { - // Put existing back; discard incoming. - let mut kept = existing; - kept.warnings.push(format!( - "name '{}' also declared in {:?} scope at {} (ignored)", - kept.name, - skill.scope, - skill + // A workflow's runnable identity is `dir_name`, while `name` is only + // display metadata. Collapse on either so a profile-local `foo/` also + // shadows a global `foo/` whose frontmatter happens to use a different + // display name. Otherwise registry lookup by slug could nondeterministically + // select the global copy. + let collision_keys: Vec = by_name + .iter() + .filter(|(existing_name, existing)| { + existing_name.as_str() == key || existing.dir_name == skill.dir_name + }) + .map(|(existing_name, _)| existing_name.clone()) + .collect(); + + if let Some((_, highest_name, highest_scope)) = collision_keys + .iter() + .filter_map(|collision_key| by_name.get(collision_key)) + .map(|existing| { + ( + precedence(existing.scope), + existing.name.clone(), + existing.scope, + ) + }) + .max_by_key(|(rank, _, _)| *rank) + { + if precedence(skill.scope) < precedence(highest_scope) { + if let Some(kept) = by_name.get_mut(&highest_name) { + kept.warnings.push(format!( + "workflow id '{}' or name '{}' also declared in {:?} scope at {} (ignored)", + skill.dir_name, + skill.name, + skill.scope, + skill + .location + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()) + )); + } + continue; + } + } + + for collision_key in collision_keys { + if let Some(loser) = by_name.remove(&collision_key) { + skill.warnings.push(format!( + "shadowed {:?}-scope skill '{}' (workflow id '{}') at {}", + loser.scope, + loser.name, + loser.dir_name, + loser .location .as_deref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "".to_string()) )); - by_name.insert(key, kept); - continue; - }; - winner.warnings.push(format!( - "shadowed {:?}-scope skill at {} with same name", - loser.scope, - loser - .location - .as_deref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()) - )); + } } by_name.insert(key, skill); } @@ -284,6 +390,8 @@ fn precedence(scope: WorkflowScope) -> u8 { WorkflowScope::Legacy => 0, WorkflowScope::User => 1, WorkflowScope::Project => 2, + // Profile-local skills win against every global scope for their owner. + WorkflowScope::Profile => 3, } } @@ -398,11 +506,50 @@ pub fn read_workflow_resource( workspace_dir: &Path, skill_id: &str, relative_path: &Path, +) -> Result { + read_workflow_resource_with_profile(workspace_dir, skill_id, relative_path, None) +} + +/// The dir_name/name set of skills discovered under a profile-local skills root. +/// +/// Used by the `describe_workflow` / `read_workflow_resource` / `run_workflow` +/// tools to treat a profile's private skills as implicitly allowed for their +/// owner (they bypass the `allowed_skills` allowlist, mirroring `list_workflows`). +/// Empty when no profile root is active, so the profile-less session and other +/// profiles are unaffected. +pub fn profile_local_skill_ids( + profile_skills_root: Option<&Path>, +) -> std::collections::HashSet { + let Some(root) = profile_skills_root else { + return std::collections::HashSet::new(); + }; + scan_root(root, WorkflowScope::Profile) + .into_iter() + .flat_map(|w| { + let mut ids = vec![w.name]; + if !w.dir_name.is_empty() { + ids.push(w.dir_name); + } + ids + }) + .collect() +} + +/// Like [`read_workflow_resource`], but resolves the skill against the active +/// profile's private skills root too (`/personalities//skills/`) +/// when `profile_skills_root` is supplied. `None` is byte-identical to +/// [`read_workflow_resource`]. +pub fn read_workflow_resource_with_profile( + workspace_dir: &Path, + skill_id: &str, + relative_path: &Path, + profile_skills_root: Option<&Path>, ) -> Result { tracing::debug!( skill_id = %skill_id, relative_path = %relative_path.display(), workspace = %workspace_dir.display(), + has_profile_root = profile_skills_root.is_some(), "[skills] read_workflow_resource: entry" ); @@ -434,10 +581,14 @@ pub fn read_workflow_resource( } // Resolve the skill by running the standard discovery pipeline. We reuse - // `load_workflow_metadata` (which honors both user and workspace roots plus the - // trust marker) so the resource read is scoped to the exact same set of - // skills the UI would already have shown the user. - let skill = resolve_workflow_for_resource(load_workflow_metadata(workspace_dir), skill_id)?; + // `load_workflow_metadata_for_profile` (which honors both user and workspace + // roots plus the trust marker, and the active profile's private root when + // supplied) so the resource read is scoped to the exact same set of skills + // the owner would already have seen listed. + let skill = resolve_workflow_for_resource( + load_workflow_metadata_for_profile(workspace_dir, profile_skills_root), + skill_id, + )?; let skill_root = skill .location .as_deref() @@ -613,3 +764,257 @@ mod include_skills_tests { ); } } + +#[cfg(test)] +mod profile_scope_tests { + use super::*; + + /// Write a minimal `WORKFLOW.md` bundle under `root/slug/`. + fn seed_bundle(root: &Path, slug: &str) { + seed_bundle_with_name(root, slug, slug); + } + + fn seed_bundle_with_name(root: &Path, slug: &str, name: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {name}\ndescription: {name} desc\n---\n\n{name} body\n"), + ) + .unwrap(); + } + + /// Profile-local skills appear ONLY when their root is passed, and never for + /// the profile-less session or a *different* profile's root (2a scoping + /// matrix). + #[test] + fn profile_local_skills_scoped_to_their_owner() { + let home = tempfile::TempDir::new().unwrap(); + // A global user-scope skill everyone sees. + seed_bundle( + &home.path().join(".openhuman").join("skills"), + "global-skill", + ); + + // Two distinct profile roots (alice / bob), each with a private skill. + let alice_root = tempfile::TempDir::new().unwrap(); + seed_bundle(alice_root.path(), "alice-only"); + let bob_root = tempfile::TempDir::new().unwrap(); + seed_bundle(bob_root.path(), "bob-only"); + + let names = |workflows: Vec| { + let mut n: Vec = workflows.into_iter().map(|w| w.name).collect(); + n.sort(); + n + }; + + // No profile: only the global skill. + let none = names(discover_workflows_with_profile( + Some(home.path()), + None, + None, + false, + )); + assert_eq!(none, vec!["global-skill"]); + + // Alice's turn: global + alice-only, never bob-only. + let alice = names(discover_workflows_with_profile( + Some(home.path()), + None, + Some(alice_root.path()), + false, + )); + assert_eq!(alice, vec!["alice-only", "global-skill"]); + + // Bob's turn: global + bob-only, never alice-only. + let bob = names(discover_workflows_with_profile( + Some(home.path()), + None, + Some(bob_root.path()), + false, + )); + assert_eq!(bob, vec!["bob-only", "global-skill"]); + } + + /// A profile-local skill named the same as a global skill wins for its owner + /// (highest precedence) and is tagged `WorkflowScope::Profile` (2a collision + /// precedence). + #[test] + fn profile_local_wins_same_name_collision() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle( + &home.path().join(".openhuman").join("skills"), + "shared-name", + ); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle(profile_root.path(), "shared-name"); + + let workflows = discover_workflows_with_profile( + Some(home.path()), + None, + Some(profile_root.path()), + false, + ); + let winner = workflows + .iter() + .find(|w| w.name == "shared-name") + .expect("shared-name resolved"); + // Exactly one entry for the name, and it is the profile-local copy. + assert_eq!( + workflows.iter().filter(|w| w.name == "shared-name").count(), + 1, + "collision must collapse to a single winner" + ); + assert_eq!( + winner.scope, + WorkflowScope::Profile, + "profile-local skill must win the same-name collision" + ); + // The winner resolves under the profile root, not the global one. + let canon_profile = std::fs::canonicalize(profile_root.path()).unwrap(); + let loc = std::fs::canonicalize(winner.location.as_ref().unwrap()).unwrap(); + assert!( + loc.starts_with(&canon_profile), + "winning skill must live under the profile root, got {}", + loc.display() + ); + } + + #[test] + fn profile_local_wins_same_runnable_id_with_different_display_name() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name( + &home.path().join(".openhuman").join("skills"), + "shared-slug", + "Global display name", + ); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name(profile_root.path(), "shared-slug", "Profile display name"); + + let workflows = discover_workflows_with_profile( + Some(home.path()), + None, + Some(profile_root.path()), + false, + ); + let by_slug: Vec<_> = workflows + .iter() + .filter(|workflow| workflow.dir_name == "shared-slug") + .collect(); + assert_eq!(by_slug.len(), 1, "runnable ids must be unique"); + assert_eq!(by_slug[0].scope, WorkflowScope::Profile); + assert_eq!(by_slug[0].name, "Profile display name"); + } + + /// `WorkflowScope::Profile` outranks every global scope in the precedence + /// ladder (the mechanism the collision test relies on). + #[test] + fn profile_scope_has_highest_precedence() { + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::Project)); + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::User)); + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::Legacy)); + } + + /// Seed a runnable bundle with a bundled resource under `references/`. + fn seed_bundle_with_resource(root: &Path, slug: &str, resource_body: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(dir.join("references")).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{slug} body\n"), + ) + .unwrap(); + std::fs::write(dir.join("references").join("note.md"), resource_body).unwrap(); + } + + /// `read_workflow_resource_with_profile` (the `read_workflow_resource` tool's + /// seam) resolves a profile's private skill resources for the owner only, + /// resolves collisions to the profile-local copy, hides them from other + /// profiles / the profile-less session, and leaves global-only resources + /// readable everywhere. + #[test] + fn read_workflow_resource_with_profile_resolution_matrix() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + let other_root = tempfile::TempDir::new().unwrap(); + + // Global (legacy) skill + resource, private skill + resource, and a + // collision under both. + seed_bundle_with_resource(&ws.path().join("skills"), "resglobal7788", "GLOBAL_RES"); + seed_bundle_with_resource(profile_root.path(), "reslocal7788", "LOCAL_RES"); + seed_bundle_with_resource(&ws.path().join("skills"), "rescollide7788", "GLOBAL_RES"); + seed_bundle_with_resource(profile_root.path(), "rescollide7788", "PROFILE_RES"); + + let rel = Path::new("references/note.md"); + let read = |id: &str, root: Option<&Path>| { + read_workflow_resource_with_profile(ws.path(), id, rel, root) + }; + + // Owner reads its private skill's resource. + assert_eq!( + read("reslocal7788", Some(profile_root.path())).unwrap(), + "LOCAL_RES" + ); + // Profile-less + other profile cannot resolve the private skill at all. + assert!(read("reslocal7788", None).is_err()); + assert!(read("reslocal7788", Some(other_root.path())).is_err()); + + // Global-only resource is readable with or without a profile root. + assert_eq!(read("resglobal7788", None).unwrap(), "GLOBAL_RES"); + assert_eq!( + read("resglobal7788", Some(profile_root.path())).unwrap(), + "GLOBAL_RES" + ); + + // Collision: owner reads the profile-local resource; everyone else the global. + assert_eq!( + read("rescollide7788", Some(profile_root.path())).unwrap(), + "PROFILE_RES" + ); + assert_eq!(read("rescollide7788", None).unwrap(), "GLOBAL_RES"); + } + + /// `profile_local_skill_ids` returns both runnable names and directory slugs + /// under the profile root (the implicit-allow set the describe/read/run tools + /// consult), and is empty for the profile-less session. + #[test] + fn profile_local_skill_ids_lists_only_the_profile_root() { + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle(profile_root.path(), "priv-a"); + seed_bundle(profile_root.path(), "priv-b"); + + let ids = profile_local_skill_ids(Some(profile_root.path())); + assert_eq!(ids.len(), 2); + assert!(ids.contains("priv-a")); + assert!(ids.contains("priv-b")); + + assert!( + profile_local_skill_ids(None).is_empty(), + "profile-less session has no implicitly-allowed profile-local ids" + ); + } + + #[test] + fn profile_local_skill_ids_include_distinct_name_and_slug() { + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name(profile_root.path(), "mail-helper", "Inbox Assistant"); + + let ids = profile_local_skill_ids(Some(profile_root.path())); + assert_eq!(ids.len(), 2); + assert!(ids.contains("mail-helper")); + assert!(ids.contains("Inbox Assistant")); + } + + /// A `None` profile root reproduces `load_workflow_metadata` byte-for-byte — + /// the back-compat guarantee for the profile-less session. + #[test] + fn none_profile_root_matches_plain_discovery() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle(&home.path().join(".openhuman").join("skills"), "a-skill"); + let with_none = discover_workflows_with_profile(Some(home.path()), None, None, false); + let plain = discover_workflows(Some(home.path()), None, false); + let names: Vec<&str> = with_none.iter().map(|w| w.name.as_str()).collect(); + let plain_names: Vec<&str> = plain.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, plain_names); + } +} diff --git a/src/openhuman/skills/ops_install.rs b/src/openhuman/skills/ops_install.rs index c24cfe02cd..3f628aaac2 100644 --- a/src/openhuman/skills/ops_install.rs +++ b/src/openhuman/skills/ops_install.rs @@ -168,7 +168,7 @@ pub(crate) async fn install_workflow_from_url_with_home( let trusted_before = is_workspace_trusted(workspace_dir); let before: std::collections::HashSet = - discover_workflows_inner(home, Some(workspace_dir), trusted_before) + discover_workflows_inner(home, Some(workspace_dir), None, trusted_before) .into_iter() .map(|s| s.name) .collect(); @@ -371,7 +371,7 @@ pub(crate) async fn install_workflow_from_url_with_home( } let trusted_after = is_workspace_trusted(workspace_dir); - let after = discover_workflows_inner(home, Some(workspace_dir), trusted_after); + let after = discover_workflows_inner(home, Some(workspace_dir), None, trusted_after); let new_skills: Vec = after .into_iter() .map(|s| s.name) diff --git a/src/openhuman/skills/ops_tests.rs b/src/openhuman/skills/ops_tests.rs index 75e2deb6bc..9a82efb2f8 100644 --- a/src/openhuman/skills/ops_tests.rs +++ b/src/openhuman/skills/ops_tests.rs @@ -15,7 +15,7 @@ fn write(path: &Path, content: &str) { /// [`discover_workflows`] explicitly (see `load_skills_surfaces_user_scope`). fn load_skills_ws(workspace_dir: &Path) -> Vec { let trusted = is_workspace_trusted(workspace_dir); - discover_workflows_inner(None, Some(workspace_dir), trusted) + discover_workflows_inner(None, Some(workspace_dir), None, trusted) } #[test] diff --git a/src/openhuman/skills/ops_types.rs b/src/openhuman/skills/ops_types.rs index ff92ae1f5d..828e0d8926 100644 --- a/src/openhuman/skills/ops_types.rs +++ b/src/openhuman/skills/ops_types.rs @@ -51,6 +51,12 @@ pub enum WorkflowScope { Project, /// Workflow discovered under the legacy `/skills/` layout. Legacy, + /// Workflow private to the active agent profile, discovered under + /// `/personalities//skills/` and surfaced ONLY for turns + /// running under that profile. Highest collision precedence — a + /// profile-local skill shadows a same-named global one for its owner. See + /// `ops_discover::discover_workflows_with_profile`. + Profile, } /// Parsed frontmatter of a `SKILL.md` file. diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index dc374b71c4..d887a716d5 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -13,6 +13,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; +use crate::openhuman::skills::WorkflowScope; /// One declared input — a parameter the skill needs, with a human description. /// `required` inputs must be supplied at run time; `kind` is an optional type @@ -153,6 +154,24 @@ pub fn prune_legacy_default_workflows(workspace_dir: &Path) { /// Without `skill.toml`, a synthesized SKILL.md-only definition means a bare workflow is /// still runnable. A bad `skill.toml` falls back to the SKILL.md-only form. pub fn load_workflows(workspace_dir: &Path) -> Vec { + load_workflows_with_profile(workspace_dir, None) +} + +/// Like [`load_workflows`], but additionally resolves the active profile's +/// private skills (`/personalities//skills/`) when +/// `profile_skills_root` is supplied. +/// +/// The profile root is threaded straight into +/// [`super::ops_discover::discover_workflows_with_profile`], so profile-local +/// skills become runnable/describable for their owner and win same-name +/// collisions against global skills (via [`WorkflowScope::Profile`] precedence). +/// `None` reproduces [`load_workflows`] byte-for-byte — other profiles and the +/// profile-less session never see these skills. No global registry state is +/// mutated, so concurrent sessions under different profiles stay isolated. +pub fn load_workflows_with_profile( + workspace_dir: &Path, + profile_skills_root: Option<&Path>, +) -> Vec { // Prune any legacy bundled skills an older build left behind so discover's // legacy scan no longer surfaces them (idempotent). prune_legacy_default_workflows(workspace_dir); @@ -173,8 +192,12 @@ pub fn load_workflows(workspace_dir: &Path) -> Vec { // discovery the create/list path uses, then load each one's definition. let home = dirs::home_dir(); let trusted = super::ops_discover::is_workspace_trusted(workspace_dir); - for wf in super::ops_discover::discover_workflows(home.as_deref(), Some(workspace_dir), trusted) - { + for wf in super::ops_discover::discover_workflows_with_profile( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) { let Some(skill_md) = wf.location.as_ref() else { continue; }; @@ -251,9 +274,54 @@ fn load_workflow_definition( /// Look up one skill by id across the registry. pub fn get_workflow(workspace_dir: &Path, id: &str) -> Option { - load_workflows(workspace_dir) + get_workflow_with_profile(workspace_dir, id, None) +} + +/// Like [`get_workflow`], but resolves the active profile's private skills too +/// (`/personalities//skills/`) when `profile_skills_root` is +/// supplied. This is the resolution seam behind `describe_workflow` / +/// `run_workflow`: a profile-local skill is runnable/describable for its owner +/// and wins same-name collisions; `None` is byte-identical to [`get_workflow`]. +pub fn get_workflow_with_profile( + workspace_dir: &Path, + id: &str, + profile_skills_root: Option<&Path>, +) -> Option { + let workflows = load_workflows_with_profile(workspace_dir, profile_skills_root); + // Built-ins are prepended and discovered workflows follow them. Search in + // reverse so the scope-resolved discovered entry (profile wins over global) + // also wins over a built-in with the same runnable id. + if let Some(exact) = workflows.iter().rev().find(|s| s.definition.id == id) { + return Some(exact.clone()); + } + + // Profile lists advertise the frontmatter display name as well as the + // directory slug. Resolve that name back to the canonical runnable slug so + // a private workflow admitted by the profile-local allow set can actually + // be described and run. Keep the legacy profile-less lookup id-only: global + // display names have never been runnable ids and may collide with builtins. + let home = dirs::home_dir(); + let trusted = super::ops_discover::is_workspace_trusted(workspace_dir); + let slug = super::ops_discover::discover_workflows_with_profile( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) + .into_iter() + .find(|workflow| workflow.scope == WorkflowScope::Profile && workflow.name == id) + .map(|workflow| { + if workflow.dir_name.is_empty() { + workflow.name + } else { + workflow.dir_name + } + })?; + + workflows .into_iter() - .find(|s| s.definition.id == id) + .rev() + .find(|workflow| workflow.definition.id == slug) } #[cfg(test)] @@ -320,6 +388,117 @@ mod tests { assert!(i.required); } + /// Seed a runnable WORKFLOW.md bundle under `root/slug/` with a distinct + /// body marker so the resolved definition can be traced back to its source. + fn seed_runnable(root: &std::path::Path, slug: &str, body_marker: &str) { + seed_runnable_with_name(root, slug, slug, body_marker); + } + + fn seed_runnable_with_name(root: &std::path::Path, slug: &str, name: &str, body_marker: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {name}\ndescription: {name} desc\n---\n\n{body_marker}\n"), + ) + .unwrap(); + } + + fn resolved_body(def: &WorkflowDefinition) -> String { + match &def.definition.system_prompt { + PromptSource::Inline(p) => p.clone(), + other => panic!("expected inline prompt, got {other:?}"), + } + } + + /// The resolution seam behind `describe_workflow` / `run_workflow` + /// (`get_workflow_with_profile`) resolves a profile's private skills for the + /// owner only, resolves collisions to the profile-local copy, keeps + /// profile-local skills invisible to other profiles / the profile-less + /// session, and leaves global-only skills resolvable everywhere. + #[test] + fn get_workflow_with_profile_resolution_matrix() { + // Unique-ish ids so a developer's real ~/.openhuman/skills can't collide. + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + let other_root = tempfile::TempDir::new().unwrap(); // a different profile + + // A global skill under the legacy `/skills/` root (no trust marker + // needed), and a private skill under the profile root. + seed_runnable(&ws.path().join("skills"), "zzglobalonly7788", "GLOBAL_BODY"); + seed_runnable(profile_root.path(), "zzlocalonly7788", "LOCAL_BODY"); + // Collision: same id in both the global legacy root and the profile root. + seed_runnable(&ws.path().join("skills"), "zzcollide7788", "GLOBAL_COLLIDE"); + seed_runnable(profile_root.path(), "zzcollide7788", "PROFILE_COLLIDE"); + + let get = |id: &str, root: Option<&std::path::Path>| { + get_workflow_with_profile(ws.path(), id, root) + }; + + // Owner resolves its profile-local skill. + assert!( + get("zzlocalonly7788", Some(profile_root.path())).is_some(), + "owner must resolve its profile-local skill" + ); + // Profile-less session and a different profile cannot resolve it. + assert!( + get("zzlocalonly7788", None).is_none(), + "profile-less session must not resolve a profile-local skill" + ); + assert!( + get("zzlocalonly7788", Some(other_root.path())).is_none(), + "a different profile must not resolve another profile's private skill" + ); + + // Global-only skill resolves everywhere (with/without a profile root). + assert!(get("zzglobalonly7788", None).is_some()); + assert!(get("zzglobalonly7788", Some(profile_root.path())).is_some()); + assert!(get("zzglobalonly7788", Some(other_root.path())).is_some()); + + // Collision: the owner resolves the profile-local copy; everyone else + // resolves the global copy. + assert_eq!( + resolved_body(&get("zzcollide7788", Some(profile_root.path())).unwrap()), + "---\nname: zzcollide7788\ndescription: zzcollide7788 desc\n---\n\nPROFILE_COLLIDE\n", + "owner must resolve the profile-local copy on collision" + ); + assert_eq!( + resolved_body(&get("zzcollide7788", None).unwrap()), + "---\nname: zzcollide7788\ndescription: zzcollide7788 desc\n---\n\nGLOBAL_COLLIDE\n", + "profile-less session resolves the global copy on collision" + ); + } + + #[test] + fn get_profile_workflow_resolves_distinct_display_name() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_runnable_with_name( + profile_root.path(), + "mail-helper", + "Inbox Assistant", + "PROFILE_NAME_BODY", + ); + + let resolved = + get_workflow_with_profile(ws.path(), "Inbox Assistant", Some(profile_root.path())) + .expect("display name must resolve for the owning profile"); + assert_eq!(resolved.definition.id, "mail-helper"); + assert!(resolved_body(&resolved).contains("PROFILE_NAME_BODY")); + assert!(get_workflow_with_profile(ws.path(), "Inbox Assistant", None).is_none()); + } + + #[test] + fn profile_workflow_exact_id_overrides_builtin() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_runnable(profile_root.path(), "critic", "PROFILE_CRITIC_BODY"); + + let resolved = get_workflow_with_profile(ws.path(), "critic", Some(profile_root.path())) + .expect("profile critic resolves"); + assert!(resolved_body(&resolved).contains("PROFILE_CRITIC_BODY")); + } + #[test] fn load_skills_reads_runtime_skill_prompt_and_inputs() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/openhuman/skills/run_log.rs b/src/openhuman/skills/run_log.rs index bbdcddcb0d..f8e1a4db8c 100644 --- a/src/openhuman/skills/run_log.rs +++ b/src/openhuman/skills/run_log.rs @@ -138,9 +138,26 @@ pub async fn write_header( inputs: &Value, task_prompt: &str, ) -> std::io::Result<()> { + write_header_with_profile(path, workflow_id, run_id, inputs, task_prompt, None).await +} + +/// Write a run header attributed to the active profile. Profile-less callers +/// retain the legacy header format through [`write_header`]. +pub async fn write_header_with_profile( + path: &Path, + workflow_id: &str, + run_id: &str, + inputs: &Value, + task_prompt: &str, + profile_id: Option<&str>, +) -> std::io::Result<()> { + let profile_line = profile_id + .map(|id| format!("profile_id: {id}\n")) + .unwrap_or_default(); let header = format!( "==== workflow_run: {skill} ====\n\ run_id : {run}\n\ + {profile_line}\ started: {start} UTC\n\ inputs : {inputs}\n\n\ --- task prompt ---\n{prompt}\n\n\ @@ -291,6 +308,9 @@ pub fn detect_repeated_line( pub struct ScannedRun { pub run_id: String, pub workflow_id: String, + /// Profile that launched the run. `None` denotes a legacy/profile-less run. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, /// Header `started:` timestamp (RFC3339); empty if header was malformed. pub started: String, /// `"DONE"` / `"DEGENERATE"` / `"FAILED"` / `"RUNNING"` (running ⇔ no footer yet). @@ -330,6 +350,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V let mut sid = String::new(); let mut rid = String::new(); let mut started = String::new(); + let mut profile_id: Option = None; let mut status = String::from("RUNNING"); let mut duration_ms: Option = None; let mut finished: Option = None; @@ -363,6 +384,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V match (label, seen_result) { // Header fields (before --- result ---) ("run_id", false) => rid = value.to_string(), + ("profile_id", false) => profile_id = Some(value.to_string()), ("started", false) => started = value.to_string(), // Footer fields (after --- result ---) ("status", true) => status = value.to_string(), @@ -391,6 +413,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V runs.push(ScannedRun { run_id: rid, workflow_id: sid, + profile_id, started, status, duration_ms, diff --git a/src/openhuman/skills/stub.rs b/src/openhuman/skills/stub.rs index c09d9b9bcc..c63de3046b 100644 --- a/src/openhuman/skills/stub.rs +++ b/src/openhuman/skills/stub.rs @@ -44,6 +44,18 @@ pub fn load_workflow_metadata(_workspace_dir: &Path) -> Vec { Vec::new() } +/// Always empty: with skills compiled out there is nothing to discover, whether +/// or not a profile-local root is supplied. Mirrors +/// [`super::ops_discover::load_workflow_metadata_for_profile`] so the harness's +/// per-profile catalog call site needs no `#[cfg]`. +pub fn load_workflow_metadata_for_profile( + _workspace_dir: &Path, + _profile_skills_root: Option<&Path>, +) -> Vec { + log::debug!("[skills-stub] load_workflow_metadata_for_profile -> [] (skills disabled)"); + Vec::new() +} + /// No-op success: with skills compiled out there is no skills directory to /// provision, and workspace bootstrap must not fail because of it. pub fn init_workflows_dir(_workspace_dir: &Path) -> Result<(), String> { diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs index 144a0b3bbd..7e5b590e9e 100644 --- a/src/openhuman/skills/tools.rs +++ b/src/openhuman/skills/tools.rs @@ -24,13 +24,17 @@ use crate::openhuman::config::Config; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use super::ops_create::{create_workflow, CreateWorkflowParams}; -use super::ops_discover::{discover_workflows, is_workspace_trusted, read_workflow_resource}; +use super::ops_discover::{ + discover_workflows_with_profile, is_workspace_trusted, profile_local_skill_ids, + read_workflow_resource_with_profile, +}; use super::ops_install::{ install_workflow_from_url, uninstall_workflow, InstallWorkflowFromUrlParams, UninstallWorkflowParams, }; -use super::registry::get_workflow; -use super::run_log::{find_run_log_path, read_run_log_slice, scan_runs}; +use super::ops_types::WorkflowScope; +use super::registry::get_workflow_with_profile; +use super::run_log::{read_run_log_slice, scan_runs}; fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { args.get(key) @@ -61,10 +65,27 @@ fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { } } +/// Whether `skill_id` is usable given the profile's allowlist AND its private +/// skills. A profile's own (profile-local) skills are implicitly allowed for +/// their owner — they bypass the `allowed_skills` allowlist, mirroring +/// `list_workflows`. `profile_local_ids` is empty for the profile-less session +/// and other profiles, so this reduces to [`skill_allowed`] there. +fn skill_allowed_including_profile( + allowlist: &SkillAllowlist, + profile_local_ids: &std::collections::HashSet, + skill_id: &str, +) -> bool { + profile_local_ids.contains(skill_id) || skill_allowed(allowlist, skill_id) +} + /// List installed skills. pub struct WorkflowListTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// 2a — the active profile's private skills root + /// (`/personalities//skills/`). `None` for the profile-less + /// session and other profiles, so the listed set is byte-identical to today. + profile_skills_root: Option, } impl WorkflowListTool { @@ -72,6 +93,7 @@ impl WorkflowListTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -81,6 +103,15 @@ impl WorkflowListTool { self.skill_allowlist = allowlist; self } + + /// Surface the active profile's private skills + /// (`/personalities//skills/`) in this list. Profile-local + /// skills are implicitly allowed for their owner (they bypass the + /// `skill_allowlist`) and win same-name collisions against global skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -104,10 +135,21 @@ impl Tool for WorkflowListTool { log::debug!("[tool][workflows] list invoked"); let home = dirs::home_dir(); let trusted = is_workspace_trusted(&self.workspace_dir); - let mut workflows = discover_workflows(home.as_deref(), Some(&self.workspace_dir), trusted); + let mut workflows = discover_workflows_with_profile( + home.as_deref(), + Some(&self.workspace_dir), + self.profile_skills_root.as_deref(), + trusted, + ); if self.skill_allowlist.is_some() { let before = workflows.len(); - workflows.retain(|w| skill_allowed(&self.skill_allowlist, &w.dir_name)); + // Profile-local skills are implicitly allowed for their owner — they + // bypass the `allowed_skills` allowlist (which scopes only global + // skills). Keep any skill whose scope is `Profile`. + workflows.retain(|w| { + w.scope == WorkflowScope::Profile + || skill_allowed(&self.skill_allowlist, &w.dir_name) + }); log::debug!( "[profiles] list_workflows scoped to profile allowlist: before={before} after={}", workflows.len() @@ -128,6 +170,9 @@ impl Tool for WorkflowListTool { pub struct WorkflowDescribeTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// Active profile's private skills root — resolves + implicitly allows the + /// owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl WorkflowDescribeTool { @@ -135,6 +180,7 @@ impl WorkflowDescribeTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -143,6 +189,12 @@ impl WorkflowDescribeTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -169,14 +221,19 @@ impl Tool for WorkflowDescribeTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] describe invoked"); let skill_id = read_workflow_id(&args)?; - if !skill_allowed(&self.skill_allowlist, &skill_id) { + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { log::debug!("[profiles] describe_workflow blocked by profile allowlist: {skill_id}"); return Ok(ToolResult::error(format!( "describe_workflow: workflow `{skill_id}` is not available to the active agent profile" ))); } - let def = get_workflow(&self.workspace_dir, &skill_id) - .ok_or_else(|| anyhow::anyhow!("describe_workflow: workflow `{skill_id}` not found"))?; + let def = get_workflow_with_profile( + &self.workspace_dir, + &skill_id, + self.profile_skills_root.as_deref(), + ) + .ok_or_else(|| anyhow::anyhow!("describe_workflow: workflow `{skill_id}` not found"))?; Ok(ToolResult::success(serde_json::to_string(&json!({ "definition": def.definition, "inputs": def.inputs, @@ -193,6 +250,9 @@ impl Tool for WorkflowDescribeTool { pub struct WorkflowReadResourceTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// Active profile's private skills root — resolves + implicitly allows the + /// owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl WorkflowReadResourceTool { @@ -200,6 +260,7 @@ impl WorkflowReadResourceTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -210,6 +271,12 @@ impl WorkflowReadResourceTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -239,7 +306,8 @@ impl Tool for WorkflowReadResourceTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] read_resource invoked"); let skill_id = read_workflow_id(&args)?; - if !skill_allowed(&self.skill_allowlist, &skill_id) { + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { log::debug!( "[profiles] read_workflow_resource blocked by profile allowlist: {skill_id}" ); @@ -248,9 +316,13 @@ impl Tool for WorkflowReadResourceTool { ))); } let relative_path = read_required_str(&args, "relative_path")?; - let content = - read_workflow_resource(&self.workspace_dir, &skill_id, Path::new(&relative_path)) - .map_err(|e| anyhow::anyhow!("read_workflow_resource: {e}"))?; + let content = read_workflow_resource_with_profile( + &self.workspace_dir, + &skill_id, + Path::new(&relative_path), + self.profile_skills_root.as_deref(), + ) + .map_err(|e| anyhow::anyhow!("read_workflow_resource: {e}"))?; Ok(ToolResult::success(serde_json::to_string(&json!({ "workflow_id": skill_id, "relative_path": relative_path, @@ -266,14 +338,38 @@ impl Tool for WorkflowReadResourceTool { /// List recent skill runs. pub struct WorkflowRecentRunsTool { workspace_dir: PathBuf, + active_profile_id: Option, + skill_allowlist: SkillAllowlist, + profile_skills_root: Option, } impl WorkflowRecentRunsTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, } } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -312,7 +408,19 @@ impl Tool for WorkflowRecentRunsTool { .and_then(serde_json::Value::as_u64) .map(|v| v as usize) .unwrap_or(20); - let runs = scan_runs(&self.workspace_dir, skill_id, limit); + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + let runs = scan_runs(&self.workspace_dir, skill_id, usize::MAX) + .into_iter() + .filter(|run| { + run.profile_id.as_deref() == self.active_profile_id.as_deref() + && skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &run.workflow_id, + ) + }) + .take(limit) + .collect::>(); Ok(ToolResult::success(serde_json::to_string(&json!({ "count": runs.len(), "runs": runs, @@ -327,14 +435,38 @@ impl Tool for WorkflowRecentRunsTool { /// Read a slice of a run log. pub struct WorkflowReadRunLogTool { workspace_dir: PathBuf, + active_profile_id: Option, + skill_allowlist: SkillAllowlist, + profile_skills_root: Option, } impl WorkflowReadRunLogTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, } } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -374,8 +506,20 @@ impl Tool for WorkflowReadRunLogTool { .and_then(serde_json::Value::as_u64) .map(|v| v as usize) .unwrap_or(65536); - let path = find_run_log_path(&self.workspace_dir, &run_id) + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + let run = scan_runs(&self.workspace_dir, None, usize::MAX) + .into_iter() + .find(|run| { + run.run_id == run_id + && run.profile_id.as_deref() == self.active_profile_id.as_deref() + && skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &run.workflow_id, + ) + }) .ok_or_else(|| anyhow::anyhow!("read_workflow_run_log: run `{run_id}` not found"))?; + let path = PathBuf::from(run.log_path); let slice = read_run_log_slice(&path, offset, max_bytes) .map_err(|e| anyhow::anyhow!("read_workflow_run_log: {e}"))?; Ok(ToolResult::success(serde_json::to_string(&slice)?)) @@ -582,6 +726,65 @@ mod tests { ); } + #[tokio::test] + async fn run_history_is_scoped_to_profile_and_allowlist() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let mut config = Config::default(); + config.workspace_dir = tmp.path().to_path_buf(); + let config = Arc::new(config); + let run_id = "aaaaaaaa-1111-2222-3333-444444444444"; + let path = + crate::openhuman::skills::run_log::run_log_path(tmp.path(), "private-flow", run_id); + crate::openhuman::skills::run_log::write_header_with_profile( + &path, + "private-flow", + run_id, + &json!({"secret": true}), + "private prompt", + Some("alice"), + ) + .await + .expect("write header"); + + let mut alice = crate::openhuman::profiles::built_in_profiles() + .into_iter() + .next() + .expect("built-in profile"); + alice.id = "alice".to_string(); + let mut bob = alice.clone(); + bob.id = "bob".to_string(); + + let alice_list = WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(Some(alice.clone())) + .execute(json!({})) + .await + .expect("alice list"); + assert!(alice_list.output_for_llm(false).contains(run_id)); + + let bob_list = WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(Some(bob.clone())) + .execute(json!({})) + .await + .expect("bob list"); + assert!(!bob_list.output_for_llm(false).contains(run_id)); + + let bob_read = WorkflowReadRunLogTool::new(config.clone()) + .with_active_profile(Some(bob)) + .execute(json!({"run_id": run_id})) + .await; + assert!(bob_read.is_err(), "another profile must not read the log"); + + let alice_disallowed = WorkflowReadRunLogTool::new(config) + .with_active_profile(Some(alice)) + .with_skill_allowlist(Some(std::collections::HashSet::new())) + .execute(json!({"run_id": run_id})) + .await; + assert!( + alice_disallowed.is_err(), + "the profile allowlist must also gate run logs" + ); + } + #[test] fn names_and_levels() { let c = cfg(); diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs index 328e56102c..ea9aa125d1 100644 --- a/src/openhuman/threads/transcript_view/project.rs +++ b/src/openhuman/threads/transcript_view/project.rs @@ -46,7 +46,14 @@ pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option Option<(PathBuf, Vec)> { let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?; let root_stem = root_path.file_stem()?.to_str()?.to_string(); - let sub_paths = discover_subagent_files(workspace_dir, &root_stem); + let Some(raw_dir) = root_path.parent() else { + log::warn!( + "{LOG_PREFIX} resolved root has no parent thread={thread_id} root={}", + root_path.display() + ); + return None; + }; + let sub_paths = discover_subagent_files(raw_dir, &root_stem); Some((root_path, sub_paths)) } @@ -96,14 +103,20 @@ pub fn project_from_files( } } -/// Discover every sub-agent transcript file for `root_stem` under -/// `session_raw/`. Sub-agent stems are `{root_stem}__…`; results are sorted so -/// the timestamp-prefixed suffixes order by creation time. -fn discover_subagent_files(workspace_dir: &Path, root_stem: &str) -> Vec { - let raw_dir = workspace_dir.join("session_raw"); +/// Discover every sub-agent transcript file beside the resolved root. +/// Sub-agent stems are `{root_stem}__…`; results are sorted so the +/// timestamp-prefixed suffixes order by creation time. +fn discover_subagent_files(raw_dir: &Path, root_stem: &str) -> Vec { let prefix = format!("{root_stem}__"); - let Ok(entries) = fs::read_dir(&raw_dir) else { - return Vec::new(); + let entries = match fs::read_dir(raw_dir) { + Ok(entries) => entries, + Err(error) => { + log::debug!( + "{LOG_PREFIX} subagent discovery read_dir failed dir={} error={error}", + raw_dir.display() + ); + return Vec::new(); + } }; let mut paths: Vec = entries .flatten() diff --git a/src/openhuman/threads/transcript_view/tests.rs b/src/openhuman/threads/transcript_view/tests.rs index 4eed3a126d..5290cefd7c 100644 --- a/src/openhuman/threads/transcript_view/tests.rs +++ b/src/openhuman/threads/transcript_view/tests.rs @@ -18,6 +18,11 @@ fn meta_line(thread_id: &str) -> String { /// `session_raw/{stem}.jsonl` and return the path. fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> PathBuf { let path = transcript::resolve_keyed_transcript_path(workspace, stem).expect("resolve"); + write_raw_at(&path, thread_id, body); + path +} + +fn write_raw_at(path: &Path, thread_id: &str, body: &[&str]) { let mut buf = meta_line(thread_id); buf.push('\n'); for line in body { @@ -25,7 +30,6 @@ fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> Pa buf.push('\n'); } std::fs::write(&path, buf).expect("write raw transcript"); - path } /// A full turn: system scaffolding, a user prompt with the injected datetime @@ -230,6 +234,37 @@ fn subagent_file_projects_as_nested_item() { )); } +#[test] +fn profile_scoped_root_and_subagent_project_together() { + let dir = TempDir::new().unwrap(); + let raw_dir = dir.path().join("session_raw-alice"); + std::fs::create_dir_all(&raw_dir).unwrap(); + let root_stem = "450_orchestrator"; + let thread_id = "thr_profile"; + + write_raw_at( + &raw_dir.join(format!("{root_stem}.jsonl")), + thread_id, + &[r#"{"role":"user","content":"delegate","request_id":"req-1"}"#], + ); + write_raw_at( + &raw_dir.join(format!("{root_stem}__451_coder.jsonl")), + thread_id, + &[r#"{"role":"assistant","content":"scoped sub work"}"#], + ); + + let projected = project_thread(dir.path(), thread_id).expect("project scoped thread"); + assert!(projected.items.iter().any(|item| matches!( + item, + DisplayItem::Subagent { items, .. } + if items.iter().any(|inner| matches!( + inner, + DisplayItem::AssistantMessage { content, .. } + if content == "scoped sub work" + )) + ))); +} + #[test] fn get_page_paginates_newest_first_with_cursor() { let dir = TempDir::new().unwrap(); diff --git a/src/openhuman/tools/impl/filesystem/file_write.rs b/src/openhuman/tools/impl/filesystem/file_write.rs index af818c83a2..399d65eccd 100644 --- a/src/openhuman/tools/impl/filesystem/file_write.rs +++ b/src/openhuman/tools/impl/filesystem/file_write.rs @@ -9,11 +9,27 @@ use tinyagents::harness::tool::ToolExecutionContext; /// Write file contents with path sandboxing pub struct FileWriteTool { security: Arc, + approval_workspace_root: Option, } impl FileWriteTool { pub fn new(security: Arc) -> Self { - Self { security } + Self { + security, + approval_workspace_root: None, + } + } + + /// Use the same effective workspace root for approval routing that tool + /// execution receives through its [`ToolExecutionContext`]. + pub fn with_approval_workspace_root( + security: Arc, + approval_workspace_root: std::path::PathBuf, + ) -> Self { + Self { + security, + approval_workspace_root: Some(approval_workspace_root), + } } } @@ -73,7 +89,10 @@ impl Tool for FileWriteTool { let target = if std::path::Path::new(path).is_absolute() { std::path::PathBuf::from(path) } else { - self.security.action_dir.join(path) + self.approval_workspace_root + .as_deref() + .unwrap_or(&self.security.action_dir) + .join(path) }; // Sync `stat` — intentionally blocking, since the `Tool` trait makes // this method sync. Fast for local paths; would only need @@ -233,6 +252,24 @@ mod tests { assert!(required.contains(&json!("content"))); } + #[test] + fn approval_probe_uses_effective_workspace_root() { + let root = tempfile::tempdir().expect("root"); + let profile_workspace = root.path().join("profiles/alice"); + std::fs::create_dir_all(&profile_workspace).expect("profile workspace"); + std::fs::write(profile_workspace.join("notes.md"), "existing").expect("seed file"); + + let tool = FileWriteTool::with_approval_workspace_root( + test_security(root.path().to_path_buf()), + profile_workspace, + ); + + assert!(tool.external_effect_with_args(&json!({ + "path": "notes.md", + "content": "updated" + }))); + } + #[tokio::test] async fn file_write_creates_file() { let dir = std::env::temp_dir().join("openhuman_test_file_write"); diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 5435dc010e..1425525aec 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -19,6 +19,7 @@ mod update_check; mod workspace_state; use crate::openhuman::security::SecurityPolicy; +use std::path::Path; use tinyagents::harness::tool::ToolExecutionContext; pub use current_time::CurrentTimeTool; @@ -62,3 +63,63 @@ pub(super) fn security_for_tool_context( } scoped } + +/// Apply the dedicated-workspace profile boundary to an arbitrary process +/// command before it is spawned. Process tools do not funnel their runtime file +/// writes through `SecurityPolicy::validate_path`, so shell, Node, and npm must +/// all share this defense-in-depth scan. +pub(super) fn check_cross_profile_command( + security: &SecurityPolicy, + command: &str, + cwd: &Path, + tool: &str, +) -> Result<(), String> { + let Some(guard) = security.active_profile.as_ref() else { + return Ok(()); + }; + // Classify cwd itself before scanning command tokens. A process tool may + // accept a syntactically in-profile directory that is actually a symlink + // into a sibling; once spawned there, npm lifecycle hooks or a shell can + // mutate that sibling without mentioning its path in the command. + let other_id = match crate::openhuman::profiles::classify_cross_profile_target( + &guard.action_dir, + &guard.profile_id, + cwd, + ) { + crate::openhuman::profiles::CrossProfileDecision::Block { other_id } => Some(other_id), + crate::openhuman::profiles::CrossProfileDecision::Allow => { + crate::openhuman::profiles::scan_command_for_cross_profile( + command, + cwd, + &guard.action_dir, + &guard.profile_id, + ) + } + }; + let Some(other_id) = other_id else { + return Ok(()); + }; + + tracing::warn!( + tool, + active_profile = %guard.profile_id, + other_profile = %other_id, + "[profiles] cross-profile process command blocked" + ); + if other_id == crate::openhuman::profiles::PROFILES_ROOT_SENTINEL { + Err(format!( + "{} Cross-profile access blocked: profile '{}' may not modify the shared profiles \ + root. Stay within your own profile directory; do not retry this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + )) + } else { + Err(format!( + "{} Cross-profile access blocked: profile '{}' may not touch profile '{}'s workspace. \ + Stay within your own profile directory; do not retry this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + other_id + )) + } +} diff --git a/src/openhuman/tools/impl/system/node_exec.rs b/src/openhuman/tools/impl/system/node_exec.rs index 789e8f6eec..d2c579bb91 100644 --- a/src/openhuman/tools/impl/system/node_exec.rs +++ b/src/openhuman/tools/impl/system/node_exec.rs @@ -204,6 +204,21 @@ impl NodeExecTool { "[policy-blocked] Action blocked: the agent is in read-only mode and cannot execute code.", )); } + let path_policy = super::security_for_tool_context(&self.security, context, "node_exec"); + let guard_command = inline_code.clone().unwrap_or_else(|| { + std::iter::once(script_path.as_deref().unwrap_or_default()) + .chain(extra_args.iter().map(String::as_str)) + .collect::>() + .join(" ") + }); + if let Err(reason) = super::check_cross_profile_command( + &path_policy, + &guard_command, + &path_policy.action_dir, + "node_exec", + ) { + return Ok(ToolResult::error(reason)); + } if self.security.is_rate_limited() { return Ok(ToolResult::error( "Rate limit exceeded: too many actions in the last hour", @@ -232,8 +247,6 @@ impl NodeExecTool { "[node_exec] starting invocation" ); - let path_policy = super::security_for_tool_context(&self.security, context, "node_exec"); - let command = if let Some(code) = inline_code.as_deref() { format!( "{} -e {}", @@ -656,4 +669,45 @@ mod tests { resolved.display() ); } + + #[tokio::test] + async fn inline_code_cannot_write_to_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + std::fs::create_dir_all(action_root.join("profiles/bob")).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NodeExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "inline_code": "require('fs').writeFileSync('../bob/loot.txt', 'x')" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } } diff --git a/src/openhuman/tools/impl/system/npm_exec.rs b/src/openhuman/tools/impl/system/npm_exec.rs index 8d0ede92cb..0b6dc749cc 100644 --- a/src/openhuman/tools/impl/system/npm_exec.rs +++ b/src/openhuman/tools/impl/system/npm_exec.rs @@ -229,6 +229,20 @@ impl NpmExecTool { "[policy-blocked] Action blocked: the agent is in read-only mode and cannot run npm.", )); } + let path_policy = super::security_for_tool_context(&self.security, context, "npm_exec"); + let cwd = match resolve_cwd(&path_policy.action_dir, cwd_override.as_deref()) { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + let guard_command = std::iter::once(subcommand.as_str()) + .chain(extra_args.iter().map(String::as_str)) + .collect::>() + .join(" "); + if let Err(reason) = + super::check_cross_profile_command(&path_policy, &guard_command, &cwd, "npm_exec") + { + return Ok(ToolResult::error(reason)); + } if self.security.is_rate_limited() { return Ok(ToolResult::error( "Rate limit exceeded: too many actions in the last hour", @@ -240,13 +254,6 @@ impl NpmExecTool { )); } - let path_policy = super::security_for_tool_context(&self.security, context, "npm_exec"); - - let cwd = match resolve_cwd(&path_policy.action_dir, cwd_override.as_deref()) { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - let resolved = match self.bootstrap.resolve().await { Ok(r) => r, Err(e) => { @@ -625,4 +632,93 @@ mod tests { ); } } + + #[tokio::test] + async fn args_cannot_target_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + std::fs::create_dir_all(action_root.join("profiles/bob")).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NpmExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "subcommand": "install", + "args": ["--prefix", "../bob"] + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } + + #[cfg(unix)] + #[tokio::test] + async fn symlinked_cwd_cannot_target_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + let bob = action_root.join("profiles/bob"); + std::fs::create_dir_all(&bob).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + symlink(&bob, alice.join("link")).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NpmExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "subcommand": "run", + "args": ["build"], + "cwd": "link" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } } diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index ab1bb745e3..0d0b2a6901 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -349,6 +349,20 @@ impl ShellTool { return (false, ToolResult::error(reason)); } + // Cross-profile write guard (1b), shell call site. File tools enforce + // the same boundary per-path in `SecurityPolicy::validate_path`; shell + // commands never funnel through that, so scan the command's path-shaped + // tokens against the profile's own workspace (its cwd). No-op unless the + // session runs under a dedicated-workspace profile. See + // `profiles::guard::scan_command_for_cross_profile` for the containment + // rationale (the cwd is already rooted at the profile's own dir). + let cwd = self.effective_action_dir_for_context(context); + if let Err(reason) = + super::check_cross_profile_command(self.security.as_ref(), command, &cwd, "shell") + { + return (false, ToolResult::error(reason)); + } + if self.security.is_rate_limited() { return ( false, diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index b27ca4dfc4..1d12a94910 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -78,6 +78,9 @@ pub fn all_tools( root_config, None, None, + None, + None, + None, ) } @@ -98,13 +101,17 @@ pub fn all_tools_with_runtime( action_dir: &std::path::Path, agents: &HashMap, root_config: &crate::openhuman::config::Config, + active_profile: Option<&crate::openhuman::profiles::AgentProfile>, skill_allowlist: Option<&std::collections::HashSet>, mcp_allowlist: Option<&[String]>, + profile_skills_root: Option<&std::path::Path>, + approval_workspace_root: Option<&std::path::Path>, ) -> Vec> { - // `skill_allowlist` scopes only the `skills`-gated tool registrations - // below, so it is genuinely unread when that feature is compiled out. + // `skill_allowlist` / `profile_skills_root` scope only the `skills`-gated + // tool registrations below, so they are genuinely unread when that feature + // is compiled out. #[cfg(not(feature = "skills"))] - let _ = skill_allowlist; + let _ = (active_profile, skill_allowlist, profile_skills_root); // Build a session-scoped managed Node.js bootstrap once, so ShellTool, // NodeExecTool, and NpmExecTool all share the same memoised resolution @@ -151,10 +158,18 @@ pub fn all_tools_with_runtime( python_bootstrap.as_ref().map(Arc::clone), )); + let file_write: Box = match approval_workspace_root { + Some(root) => Box::new(FileWriteTool::with_approval_workspace_root( + security.clone(), + root.to_path_buf(), + )), + None => Box::new(FileWriteTool::new(security.clone())), + }; + let mut tools: Vec> = vec![ shell, Box::new(FileReadTool::new(security.clone())), - Box::new(FileWriteTool::new(security.clone())), + file_write, // Coding-harness baseline tools (issue #1205): file navigation // + atomic editing primitives. Use these instead of falling // through to `shell` for grep/find/sed work. @@ -226,9 +241,19 @@ pub fn all_tools_with_runtime( // `await_run_outcome` — the same spawn path `openhuman.skills_run` // JSON-RPC uses, so RPC and tool callers stay in sync. #[cfg(feature = "skills")] - Box::new(RunWorkflowTool::new().with_skill_allowlist(skill_allowlist.cloned())), + Box::new( + RunWorkflowTool::new() + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] - Box::new(AwaitWorkflowTool::new()), + Box::new( + AwaitWorkflowTool::new() + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), Box::new(CurrentTimeTool::new()), // Reversibility for native tool-output compaction (Stage 1a): when a // large result is compacted with a `retrieve_tool_output("")` @@ -516,12 +541,15 @@ pub fn all_tools_with_runtime( // `tools::user_filter` (install also fetches remote content). #[cfg(feature = "skills")] Box::new( - WorkflowListTool::new(config.clone()).with_skill_allowlist(skill_allowlist.cloned()), + WorkflowListTool::new(config.clone()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] Box::new( WorkflowDescribeTool::new(config.clone()) - .with_skill_allowlist(skill_allowlist.cloned()), + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), // Skill registry tools — browse/search/install from remote registries. // Browse and search are read-only (default-ON); install is a write @@ -543,12 +571,23 @@ pub fn all_tools_with_runtime( #[cfg(feature = "skills")] Box::new( WorkflowReadResourceTool::new(config.clone()) - .with_skill_allowlist(skill_allowlist.cloned()), + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] - Box::new(WorkflowRecentRunsTool::new(config.clone())), + Box::new( + WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] - Box::new(WorkflowReadRunLogTool::new(config.clone())), + Box::new( + WorkflowReadRunLogTool::new(config.clone()) + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] Box::new(WorkflowCreateTool::new(config.clone())), #[cfg(feature = "skills")] diff --git a/src/openhuman/web_chat/session.rs b/src/openhuman/web_chat/session.rs index 43aec745a0..beee266796 100644 --- a/src/openhuman/web_chat/session.rs +++ b/src/openhuman/web_chat/session.rs @@ -1,9 +1,7 @@ -use serde_json::json; -use std::collections::HashSet; - use crate::openhuman::agent::Agent; use crate::openhuman::config::Config; use crate::openhuman::profiles::{AgentProfile, DEFAULT_PROFILE_ID}; +use serde_json::json; use super::types::SessionCacheFingerprint; @@ -109,19 +107,6 @@ pub(super) fn build_session_agent( agent_result .map(|mut agent| { - if let Some(allowed_tools) = profile - .allowed_tools - .as_ref() - .filter(|tools| !tools.is_empty()) - { - agent.set_visible_tool_names( - allowed_tools - .iter() - .map(|tool| tool.trim().to_string()) - .filter(|tool| !tool.is_empty()) - .collect::>(), - ); - } agent.set_event_context( json!({"client_id": client_id, "thread_id": thread_id}).to_string(), "web_channel", @@ -197,8 +182,11 @@ pub(super) fn build_session_fingerprint( target_agent_id, autonomy_signature: autonomy_signature(config), model_registry_signature: model_registry_signature(config), - // Any change to the resolved profile (id, allowlists, soul, …) changes - // this string and forces a session-agent rebuild — see the field doc. - profile_signature: crate::openhuman::profiles::profile_signature(profile), + // Any change to the resolved profile record or its canonical on-disk + // SOUL/MEMORY files forces a session-agent rebuild — see the field doc. + profile_signature: crate::openhuman::profiles::profile_session_signature( + &config.workspace_dir, + profile, + ), } } diff --git a/src/openhuman/web_chat/types.rs b/src/openhuman/web_chat/types.rs index 9fe1f9100f..db7d006ed3 100644 --- a/src/openhuman/web_chat/types.rs +++ b/src/openhuman/web_chat/types.rs @@ -25,12 +25,14 @@ pub(crate) struct SessionCacheFingerprint { /// change) — without this the stale session would be reused. Mirrors /// [`Self::autonomy_signature`]. pub(super) model_registry_signature: String, - /// Serialized signature of the active agent profile. The cached `Agent` + /// Hashed signature of the active agent profile record and its resolved + /// SOUL/MEMORY file contents. The cached `Agent` /// bakes in the profile's tool/skill/MCP/connector visibility and SOUL/MEMORY /// overrides at build time; switching profiles on the same thread keeps the /// same model/agent/provider, so without this the previous profile's /// capability surface would leak into the new profile's turns. Any change to - /// the resolved profile forces a rebuild. + /// the resolved profile or a direct edit to either profile file forces a + /// rebuild on the next turn. pub(super) profile_signature: String, } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index e450b95775..299985bf69 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -7828,6 +7828,228 @@ async fn json_rpc_local_ai_device_profile_and_presets() { rpc_join.abort(); } +// --------------------------------------------------------------------------- +// Agent profiles — home materialization + dedicated-memory field lifecycle +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn json_rpc_profiles_dedicated_memory_lifecycle() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _action_guard = EnvVarGuard::unset("OPENHUMAN_ACTION_DIR"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // --- upsert a custom profile opting into dedicated memory --- + // agentId "orchestrator" is always admitted without a registry (the implicit + // default), so this upsert persists even in the bare e2e core. + let upsert = post_json_rpc( + &rpc_base, + 60, + "openhuman.profiles_upsert", + json!({ + "profile": { + "id": "writer", + "name": "Writer", + "description": "Drafts crisp copy.", + "agentId": "orchestrator", + "builtIn": false, + "dedicatedMemory": true + } + }), + ) + .await; + let upsert_result = assert_no_jsonrpc_error(&upsert, "profiles_upsert"); + let upsert_payload = upsert_result.get("result").unwrap_or(upsert_result); + let writer = upsert_payload + .get("profiles") + .and_then(Value::as_array) + .expect("profiles array") + .iter() + .find(|p| p.get("id").and_then(Value::as_str) == Some("writer")) + .expect("writer profile present after upsert"); + assert_eq!( + writer.get("dedicatedMemory").and_then(Value::as_bool), + Some(true), + "dedicatedMemory should round-trip: {upsert_payload}" + ); + + // --- list must surface the field + the resolved soulMdFile path --- + let list = post_json_rpc(&rpc_base, 61, "openhuman.profiles_list", json!({})).await; + let list_result = assert_no_jsonrpc_error(&list, "profiles_list"); + let list_payload = list_result.get("result").unwrap_or(list_result); + let listed_writer = list_payload + .get("profiles") + .and_then(Value::as_array) + .expect("profiles array") + .iter() + .find(|p| p.get("id").and_then(Value::as_str) == Some("writer")) + .expect("writer present in list"); + assert_eq!( + listed_writer + .get("dedicatedMemory") + .and_then(Value::as_bool), + Some(true) + ); + // The home was materialized on upsert, so SOUL.md exists and its path is + // surfaced read-only. A shared-memory profile carries no workspaceDir. + assert!( + listed_writer + .get("soulMdFile") + .and_then(Value::as_str) + .is_some_and(|s| s.ends_with("personalities/writer/SOUL.md")), + "soulMdFile should resolve: {listed_writer}" + ); + assert!( + listed_writer.get("workspaceDir").is_none(), + "dedicated_memory (not workspace) must not surface workspaceDir" + ); + + // --- 2b: a cron agent job can be attributed to this profile, and the + // attribution round-trips through cron_add and cron_list (snake_case + // `profile_id`, matching the cron domain's wire convention). --- + let cron_add = post_json_rpc( + &rpc_base, + 64, + "openhuman.cron_add", + json!({ + "schedule": "0 9 * * *", + "prompt": "draft the daily note", + "profile_id": "writer" + }), + ) + .await; + let cron_add_result = assert_no_jsonrpc_error(&cron_add, "cron_add"); + // `RpcOutcome::single_log` wraps the job as `{ "result": , "logs": [..] }`. + let added_job = cron_add_result.get("result").unwrap_or(cron_add_result); + let job_id = added_job + .get("id") + .and_then(Value::as_str) + .expect("cron job id present") + .to_string(); + assert_eq!( + added_job.get("profile_id").and_then(Value::as_str), + Some("writer"), + "cron_add must round-trip profile_id: {cron_add_result}" + ); + + let cron_list = post_json_rpc(&rpc_base, 65, "openhuman.cron_list", json!({})).await; + let cron_list_result = assert_no_jsonrpc_error(&cron_list, "cron_list"); + let jobs = cron_list_result + .get("result") + .and_then(Value::as_array) + .expect("cron_list jobs array"); + let listed_job = jobs + .iter() + .find(|j| j.get("id").and_then(Value::as_str) == Some(job_id.as_str())) + .expect("added cron job present in cron_list"); + assert_eq!( + listed_job.get("profile_id").and_then(Value::as_str), + Some("writer"), + "cron_list must surface profile_id: {listed_job}" + ); + + // cron_update can repoint the attribution (Some(Some) over the wire). + let cron_update = post_json_rpc( + &rpc_base, + 66, + "openhuman.cron_update", + json!({ "job_id": job_id, "patch": { "profile_id": "editor" } }), + ) + .await; + let cron_update_result = assert_no_jsonrpc_error(&cron_update, "cron_update"); + let updated_job = cron_update_result + .get("result") + .unwrap_or(cron_update_result); + assert_eq!( + updated_job.get("profile_id").and_then(Value::as_str), + Some("editor"), + "cron_update must repoint profile_id: {cron_update_result}" + ); + + // cron_update with a wire `null` clears the attribution (double-option + // semantics — a plain `Option>` would silently no-op here). + let cron_clear = post_json_rpc( + &rpc_base, + 67, + "openhuman.cron_update", + json!({ "job_id": job_id, "patch": { "profile_id": null } }), + ) + .await; + let cron_clear_result = assert_no_jsonrpc_error(&cron_clear, "cron_update clear"); + let cleared_job = cron_clear_result.get("result").unwrap_or(cron_clear_result); + assert!( + cleared_job.get("profile_id").is_none_or(Value::is_null), + "cron_update patch profile_id=null must clear the attribution: {cron_clear_result}" + ); + + // ...and cron_list confirms the attribution is gone. + let cron_list_after = post_json_rpc(&rpc_base, 68, "openhuman.cron_list", json!({})).await; + let cron_list_after_result = assert_no_jsonrpc_error(&cron_list_after, "cron_list after clear"); + let jobs_after = cron_list_after_result + .get("result") + .and_then(Value::as_array) + .expect("cron_list jobs array"); + let listed_after = jobs_after + .iter() + .find(|j| j.get("id").and_then(Value::as_str) == Some(job_id.as_str())) + .expect("job present in cron_list after clear"); + assert!( + listed_after.get("profile_id").is_none_or(Value::is_null), + "cleared profile_id must not reappear in cron_list: {listed_after}" + ); + + // --- select then delete round-trips the active id --- + let select = post_json_rpc( + &rpc_base, + 62, + "openhuman.profiles_select", + json!({"profile_id": "writer"}), + ) + .await; + let select_result = assert_no_jsonrpc_error(&select, "profiles_select"); + let select_payload = select_result.get("result").unwrap_or(select_result); + assert_eq!( + select_payload + .get("activeProfileId") + .and_then(Value::as_str), + Some("writer") + ); + + let delete = post_json_rpc( + &rpc_base, + 63, + "openhuman.profiles_delete", + json!({"profile_id": "writer"}), + ) + .await; + let delete_result = assert_no_jsonrpc_error(&delete, "profiles_delete"); + let delete_payload = delete_result.get("result").unwrap_or(delete_result); + assert_eq!( + delete_payload + .get("activeProfileId") + .and_then(Value::as_str), + Some("default"), + "deleting the active custom profile falls back to default" + ); + + mock_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_local_ai_lm_studio_config_diagnostics_and_prompt() { let _env_lock = json_rpc_e2e_env_lock(); diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index de6dd9d6c9..5cfe19e676 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -64,6 +64,8 @@ fn make_profile(id: &str, name: &str) -> AgentProfile { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index e33c05f3f9..663215119f 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -298,14 +298,17 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() let action_tool = ComposioActionTool::new( arc_config, - "GMAIL_FETCH_EMAILS".to_string(), + // Use a round-local toolkit prefix so the process-global live catalog + // cache cannot inherit a GMAIL contract seeded by another raw-coverage + // module in this shared integration-test binary. + "ROUND15MAIL_FETCH_EMAILS".to_string(), "Fetch inbox".to_string(), Some(json!({ "type": "object", "properties": { "query": { "type": "string" } } })), ); - assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); + assert_eq!(action_tool.name(), "ROUND15MAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); let contract_result = action_tool .execute(json!({ "invented_filter": "from:me" })) @@ -805,6 +808,20 @@ async fn composio_backend_handler(State(state): State, request: Reque } } }, + { + "type": "function", + "function": { + "name": "ROUND15MAIL_FETCH_EMAILS", + "description": "Fetch Round15 test messages", + "parameters": { + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" } + } + } + } + }, { "type": "function", "function": { @@ -833,7 +850,7 @@ async fn composio_backend_handler(State(state): State, request: Reque })), (Method::POST, "/agent-integrations/composio/execute") => { match body.get("tool").and_then(Value::as_str) { - Some("GMAIL_FETCH_EMAILS") => ok(json!({ + Some("GMAIL_FETCH_EMAILS" | "ROUND15MAIL_FETCH_EMAILS") => ok(json!({ "data": { "messages": [{ "id": "msg-round15" }] }, "successful": true, "error": null, diff --git a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs index 765235d5e2..ecb81410db 100644 --- a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs +++ b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs @@ -1377,6 +1377,8 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { memory_dir_suffix: None, is_master: true, sort_order: Some(50), + dedicated_memory: false, + dedicated_workspace: false, }) .expect("upsert first"); let writing = first @@ -1415,6 +1417,8 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }) .expect("upsert second"); let second_profile = second @@ -1752,6 +1756,8 @@ fn agent_personality_paths_cover_safe_fallbacks_and_integration_filters() { memory_dir_suffix: Some("-7".into()), is_master: false, sort_order: Some(10), + dedicated_memory: false, + dedicated_workspace: false, }; assert_eq!(