feat(vault): expose approved markdown writes (#2936)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Zavian Wang
2026-05-30 09:19:34 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent c56897e0d1
commit b37bab7134
34 changed files with 1024 additions and 9 deletions
@@ -41,6 +41,8 @@ function vault(overrides: Record<string, unknown> = {}) {
created_at: '2026-05-17T10:00:00Z',
last_synced_at: null,
file_count: 0,
write_state: 'writable',
write_state_reason: 'writable',
...overrides,
};
}
@@ -111,6 +113,35 @@ describe('<VaultPanel />', () => {
expect(screen.getByText('A')).toBeTruthy();
expect(screen.getByText(/42 file/)).toBeTruthy();
expect(screen.getByText(/synced 30s ago/)).toBeTruthy();
expect(screen.getByTestId('vault-write-state-v-A')).toHaveTextContent('Writable');
expect(screen.getByText(/Approved markdown/)).toBeTruthy();
});
it('shows read-only and unavailable vault write states with reasons', async () => {
mockList.mockResolvedValueOnce({
result: [
vault({
id: 'v-readonly',
name: 'Read only',
write_state: 'read_only',
write_state_reason: 'read_only',
}),
vault({
id: 'v-missing',
name: 'Missing',
write_state: 'unavailable',
write_state_reason: 'unavailable',
}),
],
logs: [],
});
render(<VaultPanel />);
await waitFor(() => screen.getByTestId('vault-list'));
expect(screen.getByTestId('vault-write-state-v-readonly')).toHaveTextContent('Read-only');
expect(screen.getByText('Vault folder is read-only on this device.')).toBeTruthy();
expect(screen.getByTestId('vault-write-state-v-missing')).toHaveTextContent('Unavailable');
expect(screen.getByText('Vault folder is not available on this device.')).toBeTruthy();
});
it('toggles the add form and creates a vault on submit', async () => {
@@ -359,6 +359,12 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
<ul className="divide-y divide-stone-100 dark:divide-neutral-800" data-testid="vault-list">
{vaults.map(v => {
const state = busy[v.id];
const writeStateReason = v.write_state_reason
? t(
`vault.writeState.reasons.${v.write_state_reason}`,
t('vault.writeState.unknownReason')
)
: t('vault.writeState.unknownReason');
return (
<li key={v.id} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0 flex-1">
@@ -379,6 +385,17 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
)
: t('vault.neverSynced')}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span
className={writeStateBadgeClass(v.write_state)}
title={writeStateReason}
data-testid={`vault-write-state-${v.id}`}>
{t(`vault.writeState.${v.write_state}`)}
</span>
<span className="text-[11px] text-stone-400 dark:text-neutral-500">
{writeStateReason}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
@@ -425,6 +442,19 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
);
}
function writeStateBadgeClass(state: CoreVault['write_state']): string {
const base = 'inline-flex h-5 items-center rounded px-1.5 text-[10px] font-semibold';
switch (state) {
case 'writable':
return `${base} bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300`;
case 'read_only':
return `${base} bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300`;
case 'unavailable':
default:
return `${base} bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300`;
}
}
function formatRelative(iso: string, translate: (key: string) => string): string {
const timestamp = new Date(iso).getTime();
if (Number.isNaN(timestamp)) return iso;
+10
View File
@@ -4041,6 +4041,16 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} ملف (ملفات)',
'vault.syncedRelative': 'تمت مزامنته {time}',
'vault.neverSynced': 'لم تتم مزامنته أبدًا',
'vault.writeState.writable': 'قابل للكتابة',
'vault.writeState.read_only': 'للقراءة فقط',
'vault.writeState.unavailable': 'غير متاح',
'vault.writeState.unknownReason': 'حالة الكتابة غير معروفة.',
'vault.writeState.reasons.writable':
'يمكن حفظ عمليات كتابة Markdown/wiki الموافق عليها في هذا المخزن.',
'vault.writeState.reasons.read_only': 'مجلد المخزن للقراءة فقط على هذا الجهاز.',
'vault.writeState.reasons.unavailable': 'مجلد المخزن غير متاح على هذا الجهاز.',
'vault.writeState.reasons.not_directory': 'مسار المخزن ليس دليلاً.',
'vault.writeState.reasons.empty_path': 'مسار مجلد المخزن فارغ.',
'vault.syncingProgress': 'جارٍ المزامنة… {ingested}/{total}',
'vault.removing': 'جارٍ الإزالة...',
'vault.relative.sec': 'قبل {count}s',
+9
View File
@@ -4107,6 +4107,15 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} ফাইল(গুলি)',
'vault.syncedRelative': 'সিঙ্ক করা হয়েছে {time}',
'vault.neverSynced': 'কখনই সিঙ্ক করা হয়নি',
'vault.writeState.writable': 'লেখার যোগ্য',
'vault.writeState.read_only': 'শুধু-পঠন',
'vault.writeState.unavailable': 'অনুপলব্ধ',
'vault.writeState.unknownReason': 'লেখার অবস্থা অজানা।',
'vault.writeState.reasons.writable': 'অনুমোদিত Markdown/wiki লেখা এই ভল্টে সংরক্ষণ করা যাবে।',
'vault.writeState.reasons.read_only': 'এই ডিভাইসে ভল্ট ফোল্ডারটি শুধু-পঠন।',
'vault.writeState.reasons.unavailable': 'এই ডিভাইসে ভল্ট ফোল্ডারটি উপলব্ধ নয়।',
'vault.writeState.reasons.not_directory': 'ভল্ট পাথটি কোনো ফোল্ডার নয়।',
'vault.writeState.reasons.empty_path': 'ভল্ট ফোল্ডার পাথ খালি।',
'vault.syncingProgress': 'সিঙ্ক হচ্ছে... {ingested}/{total}',
'vault.removing': 'সরানো হচ্ছে...',
'vault.relative.sec': '{count}সেকেন্ড আগে',
+10
View File
@@ -4220,6 +4220,16 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} Datei(en)',
'vault.syncedRelative': 'synchronisiert {time}',
'vault.neverSynced': 'nie synchronisiert',
'vault.writeState.writable': 'Beschreibbar',
'vault.writeState.read_only': 'Schreibgeschützt',
'vault.writeState.unavailable': 'Nicht verfügbar',
'vault.writeState.unknownReason': 'Schreibstatus ist unbekannt.',
'vault.writeState.reasons.writable':
'Genehmigte Markdown-/Wiki-Schreibvorgänge können in diesem Tresor gespeichert werden.',
'vault.writeState.reasons.read_only': 'Der Tresorordner ist auf diesem Gerät schreibgeschützt.',
'vault.writeState.reasons.unavailable': 'Der Tresorordner ist auf diesem Gerät nicht verfügbar.',
'vault.writeState.reasons.not_directory': 'Der Tresorpfad ist kein Ordner.',
'vault.writeState.reasons.empty_path': 'Der Tresorordnerpfad ist leer.',
'vault.syncingProgress': 'Synchronisierung… {ingested}/{total}',
'vault.removing': 'Entfernen…',
'vault.relative.sec': 'vor {count}s',
+9
View File
@@ -4323,6 +4323,15 @@ const en: TranslationMap = {
'vault.fileCount': '{count} file(s)',
'vault.syncedRelative': 'synced {time}',
'vault.neverSynced': 'never synced',
'vault.writeState.writable': 'Writable',
'vault.writeState.read_only': 'Read-only',
'vault.writeState.unavailable': 'Unavailable',
'vault.writeState.unknownReason': 'Write state is unknown.',
'vault.writeState.reasons.writable': 'Approved markdown/wiki writes can be saved in this vault.',
'vault.writeState.reasons.read_only': 'Vault folder is read-only on this device.',
'vault.writeState.reasons.unavailable': 'Vault folder is not available on this device.',
'vault.writeState.reasons.not_directory': 'Vault path is not a directory.',
'vault.writeState.reasons.empty_path': 'Vault folder path is empty.',
'vault.syncingProgress': 'Syncing… {ingested}/{total}',
'vault.removing': 'Removing…',
'vault.relative.sec': '{count}s ago',
+12
View File
@@ -4185,6 +4185,18 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} archivo(s)',
'vault.syncedRelative': 'sincronizado {time}',
'vault.neverSynced': 'nunca sincronizado',
'vault.writeState.writable': 'Editable',
'vault.writeState.read_only': 'Solo lectura',
'vault.writeState.unavailable': 'No disponible',
'vault.writeState.unknownReason': 'Se desconoce el estado de escritura.',
'vault.writeState.reasons.writable':
'Las escrituras Markdown/wiki aprobadas se pueden guardar en esta bóveda.',
'vault.writeState.reasons.read_only':
'La carpeta de la bóveda es de solo lectura en este dispositivo.',
'vault.writeState.reasons.unavailable':
'La carpeta de la bóveda no está disponible en este dispositivo.',
'vault.writeState.reasons.not_directory': 'La ruta de la bóveda no es una carpeta.',
'vault.writeState.reasons.empty_path': 'La ruta de la carpeta de la bóveda está vacía.',
'vault.syncingProgress': 'Sincronizando... {ingested}/{total}',
'vault.removing': 'Eliminando…',
'vault.relative.sec': 'Hace {count}s',
+12
View File
@@ -4202,6 +4202,18 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} fichier(s)',
'vault.syncedRelative': 'synchronisés {time}',
'vault.neverSynced': 'jamais synchronisés',
'vault.writeState.writable': 'Modifiable',
'vault.writeState.read_only': 'Lecture seule',
'vault.writeState.unavailable': 'Indisponible',
'vault.writeState.unknownReason': "L'état d'écriture est inconnu.",
'vault.writeState.reasons.writable':
'Les écritures Markdown/wiki approuvées peuvent être enregistrées dans ce coffre.',
'vault.writeState.reasons.read_only':
'Le dossier du coffre est en lecture seule sur cet appareil.',
'vault.writeState.reasons.unavailable':
"Le dossier du coffre n'est pas disponible sur cet appareil.",
'vault.writeState.reasons.not_directory': "Le chemin du coffre n'est pas un dossier.",
'vault.writeState.reasons.empty_path': 'Le chemin du dossier du coffre est vide.',
'vault.syncingProgress': 'Synchronisation… {ingested}/{total}',
'vault.removing': 'Suppression…',
'vault.relative.sec': 'il y a {count}s',
+9
View File
@@ -4116,6 +4116,15 @@ const messages: TranslationMap = {
'vault.fileCount': '{count}फ़ाइलें',
'vault.syncedRelative': 'समन्वयित {time}',
'vault.neverSynced': 'कभी समन्वयित नहीं किया गया',
'vault.writeState.writable': 'लिखने योग्य',
'vault.writeState.read_only': 'केवल पढ़ने योग्य',
'vault.writeState.unavailable': 'उपलब्ध नहीं',
'vault.writeState.unknownReason': 'लिखने की स्थिति अज्ञात है.',
'vault.writeState.reasons.writable': 'स्वीकृत Markdown/wiki लेखन इस वॉल्ट में सहेजे जा सकते हैं।',
'vault.writeState.reasons.read_only': 'इस डिवाइस पर वॉल्ट फ़ोल्डर केवल पढ़ने योग्य है।',
'vault.writeState.reasons.unavailable': 'इस डिवाइस पर वॉल्ट फ़ोल्डर उपलब्ध नहीं है।',
'vault.writeState.reasons.not_directory': 'वॉल्ट पथ कोई फ़ोल्डर नहीं है।',
'vault.writeState.reasons.empty_path': 'वॉल्ट फ़ोल्डर पथ खाली है।',
'vault.syncingProgress': 'सिंक हो रहा है... {ingested}/{total}',
'vault.removing': 'हटाया जा रहा है...',
'vault.relative.sec': '{count}s पहले',
+10
View File
@@ -4125,6 +4125,16 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} file',
'vault.syncedRelative': 'disinkronkan {time}',
'vault.neverSynced': 'tidak pernah disinkronkan',
'vault.writeState.writable': 'Dapat ditulis',
'vault.writeState.read_only': 'Hanya baca',
'vault.writeState.unavailable': 'Tidak tersedia',
'vault.writeState.unknownReason': 'Status tulis tidak diketahui.',
'vault.writeState.reasons.writable':
'Penulisan Markdown/wiki yang disetujui dapat disimpan di vault ini.',
'vault.writeState.reasons.read_only': 'Folder vault hanya baca di perangkat ini.',
'vault.writeState.reasons.unavailable': 'Folder vault tidak tersedia di perangkat ini.',
'vault.writeState.reasons.not_directory': 'Jalur vault bukan folder.',
'vault.writeState.reasons.empty_path': 'Jalur folder vault kosong.',
'vault.syncingProgress': 'Menyinkronkan… {ingested}/{total}',
'vault.removing': 'Menghapus…',
'vault.relative.sec': '{count}s yang lalu',
+12
View File
@@ -4178,6 +4178,18 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} file',
'vault.syncedRelative': 'sincronizzato {time}',
'vault.neverSynced': 'mai sincronizzato',
'vault.writeState.writable': 'Scrivibile',
'vault.writeState.read_only': 'Sola lettura',
'vault.writeState.unavailable': 'Non disponibile',
'vault.writeState.unknownReason': 'Stato di scrittura sconosciuto.',
'vault.writeState.reasons.writable':
'Le scritture Markdown/wiki approvate possono essere salvate in questo deposito.',
'vault.writeState.reasons.read_only':
'La cartella del deposito è in sola lettura su questo dispositivo.',
'vault.writeState.reasons.unavailable':
'La cartella del deposito non è disponibile su questo dispositivo.',
'vault.writeState.reasons.not_directory': 'Il percorso del deposito non è una cartella.',
'vault.writeState.reasons.empty_path': 'Il percorso della cartella del deposito è vuoto.',
'vault.syncingProgress': 'Sincronizzazione… {ingested}/{total}',
'vault.removing': 'Rimozione in corso…',
'vault.relative.sec': '{count}s fa',
+9
View File
@@ -4076,6 +4076,15 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} 파일',
'vault.syncedRelative': '동기화됨 {time}',
'vault.neverSynced': '동기화되지 않음',
'vault.writeState.writable': '쓰기 가능',
'vault.writeState.read_only': '읽기 전용',
'vault.writeState.unavailable': '사용할 수 없음',
'vault.writeState.unknownReason': '쓰기 상태를 알 수 없습니다.',
'vault.writeState.reasons.writable': '승인된 Markdown/wiki 쓰기를 이 볼트에 저장할 수 있습니다.',
'vault.writeState.reasons.read_only': '이 장치에서 볼트 폴더는 읽기 전용입니다.',
'vault.writeState.reasons.unavailable': '이 장치에서 볼트 폴더를 사용할 수 없습니다.',
'vault.writeState.reasons.not_directory': '볼트 경로가 폴더가 아닙니다.',
'vault.writeState.reasons.empty_path': '볼트 폴더 경로가 비어 있습니다.',
'vault.syncingProgress': '동기화 중… {ingested}/{total}',
'vault.removing': '제거 중…',
'vault.relative.sec': '{count}초 전',
+10
View File
@@ -4180,6 +4180,16 @@ const messages: TranslationMap = {
'vault.fileCount': 'Plików: {count}',
'vault.syncedRelative': 'zsynchronizowano {time}',
'vault.neverSynced': 'nigdy nie synchronizowano',
'vault.writeState.writable': 'Zapisywalny',
'vault.writeState.read_only': 'Tylko do odczytu',
'vault.writeState.unavailable': 'Niedostępny',
'vault.writeState.unknownReason': 'Stan zapisu jest nieznany.',
'vault.writeState.reasons.writable':
'Zatwierdzone zapisy Markdown/wiki mogą być zapisane w tym skarbcu.',
'vault.writeState.reasons.read_only': 'Folder skarbca jest tylko do odczytu na tym urządzeniu.',
'vault.writeState.reasons.unavailable': 'Folder skarbca jest niedostępny na tym urządzeniu.',
'vault.writeState.reasons.not_directory': 'Ścieżka skarbca nie jest folderem.',
'vault.writeState.reasons.empty_path': 'Ścieżka folderu skarbca jest pusta.',
'vault.syncingProgress': 'Synchronizowanie… {ingested}/{total}',
'vault.removing': 'Usuwanie…',
'vault.relative.sec': '{count} s temu',
+10
View File
@@ -4179,6 +4179,16 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} arquivo(s)',
'vault.syncedRelative': 'sincronizado {time}',
'vault.neverSynced': 'nunca sincronizado',
'vault.writeState.writable': 'Gravável',
'vault.writeState.read_only': 'Somente leitura',
'vault.writeState.unavailable': 'Indisponível',
'vault.writeState.unknownReason': 'Estado de gravação desconhecido.',
'vault.writeState.reasons.writable':
'Gravações Markdown/wiki aprovadas podem ser salvas neste cofre.',
'vault.writeState.reasons.read_only': 'A pasta do cofre é somente leitura neste dispositivo.',
'vault.writeState.reasons.unavailable': 'A pasta do cofre não está disponível neste dispositivo.',
'vault.writeState.reasons.not_directory': 'O caminho do cofre não é uma pasta.',
'vault.writeState.reasons.empty_path': 'O caminho da pasta do cofre está vazio.',
'vault.syncingProgress': 'Sincronizando… {ingested}/{total}',
'vault.removing': 'Removendo…',
'vault.relative.sec': '{count}s atrás',
+11
View File
@@ -4146,6 +4146,17 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} файлов',
'vault.syncedRelative': 'синхронизировано {time}',
'vault.neverSynced': 'никогда не синхронизировано',
'vault.writeState.writable': 'Доступно для записи',
'vault.writeState.read_only': 'Только чтение',
'vault.writeState.unavailable': 'Недоступно',
'vault.writeState.unknownReason': 'Статус записи неизвестен.',
'vault.writeState.reasons.writable':
'Одобренные записи Markdown/wiki можно сохранить в этом хранилище.',
'vault.writeState.reasons.read_only':
'Папка хранилища доступна только для чтения на этом устройстве.',
'vault.writeState.reasons.unavailable': 'Папка хранилища недоступна на этом устройстве.',
'vault.writeState.reasons.not_directory': 'Путь хранилища не является папкой.',
'vault.writeState.reasons.empty_path': 'Путь к папке хранилища пуст.',
'vault.syncingProgress': 'Синхронизация… {ingested}/{total}',
'vault.removing': 'Удаление…',
'vault.relative.sec': '{count}s назад',
+9
View File
@@ -3915,6 +3915,15 @@ const messages: TranslationMap = {
'vault.fileCount': '{count} 文件',
'vault.syncedRelative': '已同步 {time}',
'vault.neverSynced': '从未同步过',
'vault.writeState.writable': '可写',
'vault.writeState.read_only': '只读',
'vault.writeState.unavailable': '不可用',
'vault.writeState.unknownReason': '写入状态未知。',
'vault.writeState.reasons.writable': '可将已批准的 Markdown/wiki 写入保存到此保险库。',
'vault.writeState.reasons.read_only': '此设备上的保险库文件夹是只读的。',
'vault.writeState.reasons.unavailable': '此设备上的保险库文件夹不可用。',
'vault.writeState.reasons.not_directory': '保险库路径不是文件夹。',
'vault.writeState.reasons.empty_path': '保险库文件夹路径为空。',
'vault.syncingProgress': '正在同步... {ingested}/{total}',
'vault.removing': '正在删除...',
'vault.relative.sec': '{count} 秒前',
+40
View File
@@ -21,6 +21,7 @@ describe('tauriCommands/vault', () => {
let openhumanVaultCreate: typeof import('./vault').openhumanVaultCreate;
let openhumanVaultGet: typeof import('./vault').openhumanVaultGet;
let openhumanVaultFiles: typeof import('./vault').openhumanVaultFiles;
let openhumanVaultWriteMarkdown: typeof import('./vault').openhumanVaultWriteMarkdown;
let openhumanVaultRemove: typeof import('./vault').openhumanVaultRemove;
let openhumanVaultSync: typeof import('./vault').openhumanVaultSync;
let openhumanVaultSyncStatus: typeof import('./vault').openhumanVaultSyncStatus;
@@ -33,6 +34,7 @@ describe('tauriCommands/vault', () => {
openhumanVaultCreate = actual.openhumanVaultCreate;
openhumanVaultGet = actual.openhumanVaultGet;
openhumanVaultFiles = actual.openhumanVaultFiles;
openhumanVaultWriteMarkdown = actual.openhumanVaultWriteMarkdown;
openhumanVaultRemove = actual.openhumanVaultRemove;
openhumanVaultSync = actual.openhumanVaultSync;
openhumanVaultSyncStatus = actual.openhumanVaultSyncStatus;
@@ -127,6 +129,44 @@ describe('tauriCommands/vault', () => {
});
});
describe('openhumanVaultWriteMarkdown', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(
openhumanVaultWriteMarkdown({
vaultId: 'v-1',
relPath: 'wiki/a.md',
content: '# A',
approved: true,
})
).rejects.toThrow('Not running in Tauri');
});
test('dispatches openhuman.vault_write_markdown with approval state', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { vault_id: 'v-1', rel_path: 'wiki/a.md', bytes_written: 3, created: true },
logs: [],
});
await openhumanVaultWriteMarkdown({
vaultId: 'v-1',
relPath: 'wiki/a.md',
content: '# A',
overwrite: true,
approved: true,
});
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.vault_write_markdown',
params: {
vault_id: 'v-1',
rel_path: 'wiki/a.md',
content: '# A',
overwrite: true,
approved: true,
},
});
});
});
describe('openhumanVaultRemove', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
+28
View File
@@ -15,8 +15,12 @@ export interface CoreVault {
created_at: string;
last_synced_at?: string | null;
file_count: number;
write_state: CoreVaultWriteState;
write_state_reason?: string | null;
}
export type CoreVaultWriteState = 'writable' | 'read_only' | 'unavailable';
export type CoreVaultFileStatus = 'ok' | 'skipped' | 'failed';
export interface CoreVaultFile {
@@ -98,6 +102,30 @@ export async function openhumanVaultFiles(
});
}
export async function openhumanVaultWriteMarkdown(params: {
vaultId: string;
relPath: string;
content: string;
overwrite?: boolean;
approved: boolean;
}): Promise<
CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }>
> {
ensureTauri();
return await callCoreRpc<
CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }>
>({
method: 'openhuman.vault_write_markdown',
params: {
vault_id: params.vaultId,
rel_path: params.relPath,
content: params.content,
overwrite: params.overwrite ?? false,
approved: params.approved,
},
});
}
export async function openhumanVaultRemove(
vaultId: string,
purgeMemory: boolean
+1
View File
@@ -422,6 +422,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
| 11.1.9 | Vault Markdown Writes | RU/VU | `src/openhuman/vault/tests.rs`, `app/src/components/intelligence/VaultPanel.test.tsx`, `app/src/utils/tauriCommands/vault.test.ts` | ✅ | User-added vaults expose writable/read-only/unavailable state; approved markdown writes reject missing approval, path escapes, and non-markdown targets |
### 11.2 Insights Dashboard
@@ -587,6 +587,16 @@ const result = await callCoreRpc<MyType>({
Implementation: `invoke('core_rpc_relay', { request: { method, params, serviceManaged } })``app/src-tauri/src/commands/core_relay.rs` → HTTP client in `app/src-tauri/src/core_rpc.rs`.
### Knowledge Vault Write State
`VaultPanel` in `app/src/components/intelligence/` renders user-added knowledge vaults from the core `vault.*` controller surface. Each vault row must show the core-provided `write_state`:
- `writable` means approved markdown/wiki writes can be saved under the vault root.
- `read_only` means the folder exists but local permissions report it as read-only.
- `unavailable` means the folder is missing, no longer a directory, or cannot be inspected on this device.
The UI should not infer writability from the path string alone. It maps the core-provided `write_state_reason` code through i18n so unsupported write states are visible instead of failing silently. Approved artifact creation uses `openhuman.vault_write_markdown`; callers must pass `approved: true`, a relative `.md` or `.markdown` path, and content. The core rejects absolute paths, `..` traversal, non-markdown files, symlink escapes, and updates without `overwrite: true`.
### Service integration with providers
#### SocketProvider
+10
View File
@@ -310,6 +310,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.vault_markdown_writes",
name: "Vault Markdown Writes",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "Show whether a user-added local vault is writable, and write explicitly approved markdown/wiki artifacts back into that vault without leaving the device.",
how_to: "Intelligence > Memory > Knowledge vaults",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.embedding_provider_config",
name: "Configure Embedding Provider",
+1
View File
@@ -103,6 +103,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
"intelligence.mcp_server",
"intelligence.searxng_search",
"intelligence.tool_registry",
"intelligence.vault_markdown_writes",
"intelligence.embedding_provider_config",
"intelligence.embedding_provider_test",
"conversation.subagent_mascots",
@@ -11,6 +11,7 @@ mod read_diff;
mod run_linter;
mod run_tests;
mod update_memory_md;
mod vault_write_markdown;
pub use apply_patch::ApplyPatchTool;
pub use csv_export::CsvExportTool;
@@ -25,3 +26,4 @@ pub use read_diff::ReadDiffTool;
pub use run_linter::RunLinterTool;
pub use run_tests::RunTestsTool;
pub use update_memory_md::UpdateMemoryMdTool;
pub use vault_write_markdown::VaultWriteMarkdownTool;
@@ -0,0 +1,177 @@
//! Tool: vault_write_markdown — approved markdown/wiki writes into user vaults.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::config::Config;
use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
pub struct VaultWriteMarkdownTool {
config: Arc<Config>,
security: Arc<SecurityPolicy>,
}
impl VaultWriteMarkdownTool {
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
Self { config, security }
}
}
#[async_trait]
impl Tool for VaultWriteMarkdownTool {
fn name(&self) -> &str {
"vault_write_markdown"
}
fn description(&self) -> &str {
"Create or update an approved .md/.markdown wiki note inside a registered knowledge vault. \
Use only after the user has approved writing generated content into that vault."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["vault_id", "rel_path", "content"],
"properties": {
"vault_id": {
"type": "string",
"description": "Identifier of the registered vault to write into."
},
"rel_path": {
"type": "string",
"description": "Relative path under the vault root. Must end with .md or .markdown."
},
"content": {
"type": "string",
"description": "Markdown content to write."
},
"overwrite": {
"type": "boolean",
"description": "Set true to update an existing markdown file."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
self.security.gate_decision(CommandClass::Write) == GateDecision::Prompt
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
if !self.security.can_act() {
return Ok(ToolResult::error(
"[policy-blocked] Action blocked: autonomy is read-only",
));
}
if self.security.is_rate_limited() {
return Ok(ToolResult::error(
"Rate limit exceeded: too many actions in the last hour",
));
}
let vault_id = args
.get("vault_id")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'vault_id' parameter"))?;
let rel_path = args
.get("rel_path")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'rel_path' parameter"))?;
let content = args
.get("content")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
let overwrite = args
.get("overwrite")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if !self.security.record_action() {
return Ok(ToolResult::error(
"Rate limit exceeded: action budget exhausted",
));
}
tracing::debug!(
vault_id = %vault_id,
rel_path = %rel_path,
overwrite,
content_bytes = content.len(),
"[vault_write_markdown] execute"
);
match crate::openhuman::vault::ops::vault_write_markdown(
&self.config,
vault_id,
rel_path,
content,
overwrite,
true,
)
.await
{
Ok(outcome) => Ok(ToolResult::json(json!(outcome.value))),
Err(err) => Ok(ToolResult::error(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::security::AutonomyLevel;
fn security(workspace: std::path::PathBuf, autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy,
workspace_dir: workspace,
..SecurityPolicy::default()
})
}
#[test]
fn vault_write_markdown_tool_schema_requires_core_fields() {
let dir = tempfile::tempdir().unwrap();
let config = Arc::new(Config::default());
let tool = VaultWriteMarkdownTool::new(
config,
security(dir.path().to_path_buf(), AutonomyLevel::Supervised),
);
assert_eq!(tool.name(), "vault_write_markdown");
assert_eq!(tool.permission_level(), PermissionLevel::Write);
assert!(tool.external_effect_with_args(&json!({})));
let schema = tool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|value| value == "vault_id"));
assert!(required.iter().any(|value| value == "rel_path"));
assert!(required.iter().any(|value| value == "content"));
}
#[tokio::test]
async fn vault_write_markdown_tool_blocks_read_only_autonomy() {
let dir = tempfile::tempdir().unwrap();
let config = Arc::new(Config::default());
let tool = VaultWriteMarkdownTool::new(
config,
security(dir.path().to_path_buf(), AutonomyLevel::ReadOnly),
);
let result = tool
.execute(json!({
"vault_id": "v-1",
"rel_path": "wiki/a.md",
"content": "# A"
}))
.await
.unwrap();
assert!(result.is_error);
assert!(result.text().contains("read-only"));
}
}
+4
View File
@@ -133,6 +133,10 @@ pub fn all_tools_with_runtime(
shell,
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security.clone())),
Box::new(VaultWriteMarkdownTool::new(
config.clone(),
security.clone(),
)),
// Coding-harness baseline tools (issue #1205): file navigation
// + atomic editing primitives. Use these instead of falling
// through to `shell` for grep/find/sed work.
+38
View File
@@ -177,6 +177,43 @@ fn all_tools_includes_spawn_parallel_agents() {
);
}
#[test]
fn all_tools_includes_vault_write_markdown() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::openhuman::memory_store::create_memory(&mem_cfg, tmp.path()).unwrap());
let browser = BrowserConfig {
enabled: false,
allowed_domains: vec![],
session_name: None,
..BrowserConfig::default()
};
let http = crate::openhuman::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let tools = all_tools(
Arc::new(cfg.clone()),
&security,
AuditLogger::disabled(),
mem,
&browser,
&http,
tmp.path(),
&HashMap::new(),
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"vault_write_markdown"),
"vault_write_markdown must be registered so agents can write approved markdown into user vaults; got: {names:?}"
);
}
#[test]
fn all_tools_always_registers_curl() {
// Regression guard: `curl` is always registered (gated only by
@@ -382,6 +419,7 @@ fn all_tools_default_registry_contains_expected_baseline_surface() {
"shell",
"file_read",
"file_write",
"vault_write_markdown",
"grep",
"glob",
"list",
+4 -1
View File
@@ -9,7 +9,10 @@ const TOOL_ID_TO_RUST_NAMES: &[(&str, &[&str])] = &[
("install_tool", &["install_tool"]),
("git_operations", &["git_operations"]),
("file_read", &["file_read", "read_diff", "csv_export"]),
("file_write", &["file_write", "update_memory_md"]),
(
"file_write",
&["file_write", "update_memory_md", "vault_write_markdown"],
),
("screenshot", &["screenshot"]),
("image_info", &["image_info"]),
("browser_open", &["browser_open"]),
+1
View File
@@ -18,6 +18,7 @@ pub use schemas::{
};
pub use types::{
Vault, VaultFile, VaultFileStatus, VaultSyncReport, VaultSyncState, VaultSyncStatus,
VaultWriteMarkdownReport, VaultWriteState,
};
#[cfg(test)]
+163 -5
View File
@@ -3,6 +3,7 @@
use chrono::Utc;
use futures::FutureExt;
use sha2::{Digest, Sha256};
use std::path::{Component, Path, PathBuf};
use uuid::Uuid;
use crate::openhuman::config::Config;
@@ -14,7 +15,9 @@ use crate::rpc::RpcOutcome;
use super::state;
use super::store;
use super::sync;
use super::types::{Vault, VaultFile, VaultSyncState, VaultSyncStatus};
use super::types::{
Vault, VaultFile, VaultSyncState, VaultSyncStatus, VaultWriteMarkdownReport, VaultWriteState,
};
/// Derive a stable memory namespace for a vault without embedding the raw UUID.
///
@@ -63,13 +66,15 @@ pub async fn vault_create(
exclude_globs.len(),
);
let namespace = vault_namespace_for_id(&id);
let canonical_root = root
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed_root.to_string());
let (write_state, write_state_reason) = store::vault_write_state_for_root_path(&canonical_root);
let vault = Vault {
id: id.clone(),
name: trimmed_name.to_string(),
root_path: root
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed_root.to_string()),
root_path: canonical_root,
host_os: Some(store::current_host_os().to_string()),
namespace,
include_globs,
@@ -77,6 +82,8 @@ pub async fn vault_create(
created_at: Utc::now(),
last_synced_at: None,
file_count: 0,
write_state,
write_state_reason,
};
store::insert_vault(config, &vault).map_err(|e| e.to_string())?;
@@ -104,6 +111,81 @@ pub async fn vault_get(config: &Config, id: &str) -> Result<RpcOutcome<Vault>, S
Ok(RpcOutcome::single_log(vault, "vault loaded"))
}
/// Write an explicitly approved markdown/wiki artifact into a registered vault.
pub async fn vault_write_markdown(
config: &Config,
id: &str,
rel_path: &str,
content: &str,
overwrite: bool,
approved: bool,
) -> Result<RpcOutcome<VaultWriteMarkdownReport>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
if !approved {
log::debug!("[vault] write_markdown: rejected missing approval id={id}");
return Err("vault markdown writes require explicit user approval".to_string());
}
let vault = store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
let (write_state, write_reason) = store::vault_write_state_for_root_path(&vault.root_path);
if write_state != VaultWriteState::Writable {
log::debug!(
"[vault] write_markdown: rejected non-writable id={id} state={write_state:?} reason={write_reason:?}"
);
return Err(store::vault_write_state_reason_message(write_reason.as_deref()).to_string());
}
let rel = validate_markdown_rel_path(rel_path)?;
let bytes = content.as_bytes().len() as u64;
let root = std::fs::canonicalize(&vault.root_path)
.map_err(|err| format!("failed to resolve vault folder: {err}"))?;
ensure_existing_ancestors_stay_in_root(&root, &rel)?;
let target = root.join(&rel);
let parent = target
.parent()
.ok_or_else(|| "target path has no parent directory".to_string())?;
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create vault note directory: {err}"))?;
let parent_canon = std::fs::canonicalize(parent)
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
if !parent_canon.starts_with(&root) {
return Err("vault note path resolves outside the vault folder".to_string());
}
if let Ok(meta) = std::fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err("refusing to write through a symlink inside the vault".to_string());
}
}
let created = !target.exists();
if !created && !overwrite {
return Err(
"vault markdown file already exists; set overwrite=true to update it".to_string(),
);
}
log::debug!(
"[vault] write_markdown: writing id={id} rel_path={} bytes={bytes} overwrite={overwrite} created={created}",
rel.display()
);
std::fs::write(&target, content)
.map_err(|err| format!("failed to write vault markdown file: {err}"))?;
Ok(RpcOutcome::single_log(
VaultWriteMarkdownReport {
vault_id: id.to_string(),
rel_path: rel.to_string_lossy().replace('\\', "/"),
bytes_written: bytes,
created,
},
format!("vault markdown written: {id}"),
))
}
pub async fn vault_files(config: &Config, id: &str) -> Result<RpcOutcome<Vec<VaultFile>>, String> {
let id = id.trim();
if id.is_empty() {
@@ -117,6 +199,82 @@ pub async fn vault_files(config: &Config, id: &str) -> Result<RpcOutcome<Vec<Vau
Ok(RpcOutcome::single_log(files, "vault files listed"))
}
fn validate_markdown_rel_path(raw: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("rel_path must not be empty".to_string());
}
let path = Path::new(trimmed);
if path.is_absolute() {
return Err("rel_path must be relative to the vault folder".to_string());
}
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir => {
return Err("rel_path must not contain '..' segments".to_string());
}
Component::RootDir | Component::Prefix(_) => {
return Err("rel_path must stay inside the vault folder".to_string());
}
}
}
if clean.as_os_str().is_empty() {
return Err("rel_path must name a markdown file".to_string());
}
let ext = clean
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
if !matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown") {
return Err("rel_path must end with .md or .markdown".to_string());
}
Ok(clean)
}
fn ensure_existing_ancestors_stay_in_root(root: &Path, rel_path: &Path) -> Result<(), String> {
let Some(parent) = rel_path.parent() else {
return Ok(());
};
let mut current = root.to_path_buf();
for component in parent.components() {
let Component::Normal(part) = component else {
continue;
};
let next = current.join(part);
match std::fs::symlink_metadata(&next) {
Ok(meta) if meta.file_type().is_symlink() => {
let resolved = std::fs::canonicalize(&next)
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
if !resolved.starts_with(root) {
return Err(
"vault note directory resolves outside the vault folder".to_string()
);
}
current = resolved;
}
Ok(meta) if meta.is_dir() => {
current = next;
}
Ok(_) => {
return Err("vault note parent path is not a directory".to_string());
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
break;
}
Err(err) => {
return Err(format!("failed to inspect vault note directory: {err}"));
}
}
}
Ok(())
}
pub async fn vault_remove(
config: &Config,
id: &str,
+60
View File
@@ -21,6 +21,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("list"),
schemas("get"),
schemas("files"),
schemas("write_markdown"),
schemas("remove"),
schemas("sync"),
schemas("sync_status"),
@@ -45,6 +46,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("files"),
handler: handle_files,
},
RegisteredController {
schema: schemas("write_markdown"),
handler: handle_write_markdown,
},
RegisteredController {
schema: schemas("remove"),
handler: handle_remove,
@@ -139,6 +144,44 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"write_markdown" => ControllerSchema {
namespace: "vault",
function: "write_markdown",
description: "Write an explicitly approved markdown/wiki file inside a writable vault.",
inputs: vec![
vault_id_input("Identifier of the vault to write into."),
FieldSchema {
name: "rel_path",
ty: TypeSchema::String,
comment: "Relative .md/.markdown path inside the vault.",
required: true,
},
FieldSchema {
name: "content",
ty: TypeSchema::String,
comment: "Markdown content to write.",
required: true,
},
FieldSchema {
name: "overwrite",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "When true, update an existing markdown file.",
required: false,
},
FieldSchema {
name: "approved",
ty: TypeSchema::Bool,
comment: "Must be true after explicit user approval for arbitrary vault writes.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "report",
ty: TypeSchema::Ref("VaultWriteMarkdownReport"),
comment: "Write result with relative path and byte count.",
required: true,
}],
},
"remove" => ControllerSchema {
namespace: "vault",
function: "remove",
@@ -283,6 +326,23 @@ fn handle_files(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_write_markdown(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
let rel_path = read_required::<String>(&params, "rel_path")?;
let content = read_required::<String>(&params, "content")?;
let overwrite = read_optional::<bool>(&params, "overwrite")?.unwrap_or(false);
let approved = read_required::<bool>(&params, "approved")?;
to_json(
crate::openhuman::vault::ops::vault_write_markdown(
&config, &vault_id, &rel_path, &content, overwrite, approved,
)
.await?,
)
})
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+67 -2
View File
@@ -8,10 +8,16 @@ use rusqlite::{params, Connection, OptionalExtension};
use crate::openhuman::config::Config;
use super::types::{Vault, VaultFile, VaultFileStatus};
use super::types::{Vault, VaultFile, VaultFileStatus, VaultWriteState};
static MIGRATED_VAULT_DBS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
pub(crate) const VAULT_WRITE_REASON_EMPTY_PATH: &str = "empty_path";
pub(crate) const VAULT_WRITE_REASON_UNAVAILABLE: &str = "unavailable";
pub(crate) const VAULT_WRITE_REASON_NOT_DIRECTORY: &str = "not_directory";
pub(crate) const VAULT_WRITE_REASON_READ_ONLY: &str = "read_only";
pub(crate) const VAULT_WRITE_REASON_WRITABLE: &str = "writable";
pub(crate) fn with_connection<T>(
config: &Config,
f: impl FnOnce(&Connection) -> Result<T>,
@@ -225,10 +231,12 @@ fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result<Vault> {
let created_raw: String = row.get(7)?;
let last_raw: Option<String> = row.get(8)?;
let file_count: i64 = row.get(9)?;
let root_path: String = row.get(2)?;
let (write_state, write_state_reason) = vault_write_state_for_root_path(&root_path);
Ok(Vault {
id: row.get(0)?,
name: row.get(1)?,
root_path: row.get(2)?,
root_path,
host_os: row.get(3)?,
namespace: row.get(4)?,
include_globs: serde_json::from_str(&include_raw).unwrap_or_default(),
@@ -236,6 +244,8 @@ fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result<Vault> {
created_at: parse_dt(&created_raw),
last_synced_at: last_raw.as_deref().map(parse_dt),
file_count: file_count.max(0) as u64,
write_state,
write_state_reason,
})
}
@@ -282,6 +292,61 @@ pub(crate) fn current_host_os() -> &'static str {
std::env::consts::OS
}
pub(crate) fn vault_write_state_for_root_path(
root_path: &str,
) -> (VaultWriteState, Option<String>) {
let trimmed = root_path.trim();
if trimmed.is_empty() {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_EMPTY_PATH.to_string()),
);
}
let path = std::path::Path::new(trimmed);
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(_) => {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_UNAVAILABLE.to_string()),
)
}
};
if !metadata.is_dir() {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_NOT_DIRECTORY.to_string()),
);
}
if metadata.permissions().readonly() {
return (
VaultWriteState::ReadOnly,
Some(VAULT_WRITE_REASON_READ_ONLY.to_string()),
);
}
(
VaultWriteState::Writable,
Some(VAULT_WRITE_REASON_WRITABLE.to_string()),
)
}
pub(crate) fn vault_write_state_reason_message(reason_code: Option<&str>) -> &'static str {
match reason_code {
Some(VAULT_WRITE_REASON_EMPTY_PATH) => "Vault folder path is empty.",
Some(VAULT_WRITE_REASON_UNAVAILABLE) => "Vault folder is not available on this device.",
Some(VAULT_WRITE_REASON_NOT_DIRECTORY) => "Vault path is not a directory.",
Some(VAULT_WRITE_REASON_READ_ONLY) => "Vault folder is read-only on this device.",
Some(VAULT_WRITE_REASON_WRITABLE) => {
"Approved markdown/wiki writes can be saved in this vault."
}
_ => "Vault write state is unknown.",
}
}
pub(crate) fn path_looks_compatible_with_host_os(raw_path: &str, host_os: &str) -> bool {
let path = raw_path.trim();
if path.is_empty() {
+3
View File
@@ -702,6 +702,7 @@ mod sync_tests {
use super::*;
use crate::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested};
use crate::openhuman::vault::ops;
use crate::openhuman::vault::VaultWriteState;
use tempfile::TempDir;
/// Test-config pattern mirrors `memory::sync_pipeline_e2e_test::test_config`:
@@ -730,6 +731,8 @@ mod sync_tests {
created_at: Utc::now(),
last_synced_at: None,
file_count: 0,
write_state: VaultWriteState::Writable,
write_state_reason: None,
}
}
+189 -1
View File
@@ -10,7 +10,9 @@ use super::ops;
use super::state;
use super::store;
use super::sync::supported_extension;
use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus};
use super::types::{
Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus, VaultWriteState,
};
fn make_config(tmp: &TempDir) -> Config {
let mut config = Config::default();
@@ -30,6 +32,8 @@ fn sample_vault(root: PathBuf) -> Vault {
created_at: chrono::Utc::now(),
last_synced_at: None,
file_count: 0,
write_state: VaultWriteState::Writable,
write_state_reason: None,
}
}
@@ -99,6 +103,31 @@ fn store_stamps_new_vaults_with_current_host_os() {
let listed = store::list_vaults(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].host_os.as_deref(), Some(std::env::consts::OS));
assert_eq!(listed[0].write_state, VaultWriteState::Writable);
assert_eq!(
listed[0].write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_WRITABLE)
);
}
#[test]
fn store_marks_missing_vault_folder_unavailable_instead_of_hiding_it() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
std::fs::create_dir_all(&vault_root).unwrap();
let vault = sample_vault(vault_root.clone());
store::insert_vault(&config, &vault).unwrap();
std::fs::remove_dir_all(&vault_root).unwrap();
let listed = store::list_vaults(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].write_state, VaultWriteState::Unavailable);
assert_eq!(
listed[0].write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_UNAVAILABLE)
);
}
#[test]
@@ -342,6 +371,11 @@ async fn vault_create_returns_current_host_os() {
.unwrap();
assert_eq!(outcome.value.host_os.as_deref(), Some(std::env::consts::OS));
assert_eq!(outcome.value.write_state, VaultWriteState::Writable);
assert_eq!(
outcome.value.write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_WRITABLE)
);
}
#[tokio::test]
@@ -390,6 +424,160 @@ async fn vault_sync_status_returns_idle_for_unknown_vault() {
assert_eq!(outcome.value.ingested, 0);
}
#[tokio::test]
async fn vault_write_markdown_requires_explicit_approval() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let err = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n",
false,
false,
)
.await
.unwrap_err();
assert!(err.contains("explicit user approval"));
}
#[tokio::test]
async fn vault_write_markdown_creates_and_updates_relative_markdown() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
std::fs::create_dir_all(&vault_root).unwrap();
let vault = ops::vault_create(
&config,
"Test",
vault_root.to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let first = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nInitial.",
false,
true,
)
.await
.unwrap()
.value;
assert!(first.created);
assert_eq!(first.rel_path, "wiki/summary.md");
assert_eq!(first.bytes_written, "# Summary\n\nInitial.".len() as u64);
assert_eq!(
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
"# Summary\n\nInitial."
);
let duplicate = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nUpdated.",
false,
true,
)
.await
.unwrap_err();
assert!(duplicate.contains("already exists"));
let updated = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nUpdated.",
true,
true,
)
.await
.unwrap()
.value;
assert!(!updated.created);
assert_eq!(
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
"# Summary\n\nUpdated."
);
}
#[tokio::test]
async fn vault_write_markdown_rejects_escape_paths_and_non_markdown() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let traversal = ops::vault_write_markdown(&config, &vault.id, "../x.md", "x", false, true)
.await
.unwrap_err();
assert!(traversal.contains(".."));
let non_markdown =
ops::vault_write_markdown(&config, &vault.id, "notes/out.txt", "x", false, true)
.await
.unwrap_err();
assert!(non_markdown.contains(".md"));
}
#[cfg(unix)]
#[tokio::test]
async fn vault_write_markdown_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&vault_root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, vault_root.join("linked")).unwrap();
let vault = ops::vault_create(
&config,
"Test",
vault_root.to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let err = ops::vault_write_markdown(&config, &vault.id, "linked/escape.md", "x", false, true)
.await
.unwrap_err();
assert!(err.contains("outside the vault"));
assert!(!outside.join("escape.md").exists());
}
#[tokio::test]
async fn vault_sync_status_returns_state_when_present() {
let id = "__ops_status_running__";
+23
View File
@@ -59,6 +59,20 @@ pub struct Vault {
pub created_at: DateTime<Utc>,
pub last_synced_at: Option<DateTime<Utc>>,
pub file_count: u64,
#[serde(default)]
pub write_state: VaultWriteState,
#[serde(default)]
pub write_state_reason: Option<String>,
}
/// Whether OpenHuman can write approved markdown artifacts back into a vault.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum VaultWriteState {
Writable,
ReadOnly,
#[default]
Unavailable,
}
/// Per-file ledger entry used for dedup on re-sync.
@@ -113,3 +127,12 @@ pub struct VaultSyncReport {
pub duration_ms: i64,
pub errors: Vec<String>,
}
/// Result returned after writing an approved markdown/wiki artifact into a vault.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultWriteMarkdownReport {
pub vault_id: String,
pub rel_path: String,
pub bytes_written: u64,
pub created: bool,
}