diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 4989cfcf2..ec1b786af 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -30,6 +30,7 @@ }, "updater:default", "allow-core-process", + "allow-workspace-files", "allow-app-update" ] } diff --git a/app/src-tauri/permissions/allow-workspace-files.toml b/app/src-tauri/permissions/allow-workspace-files.toml new file mode 100644 index 000000000..1d1e08148 --- /dev/null +++ b/app/src-tauri/permissions/allow-workspace-files.toml @@ -0,0 +1,13 @@ +[[permission]] +identifier = "allow-workspace-files" +description = "Allow opening, revealing, and previewing files resolved inside the active OpenHuman workspace" + +[permission.commands] + +allow = [ + "open_workspace_path", + "reveal_workspace_path", + "preview_workspace_text", +] + +deny = [] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 12e676648..5cb60fb82 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod webview_apis; mod wechat_scanner; mod whatsapp_scanner; mod window_state; +mod workspace_paths; #[cfg(target_os = "macos")] use tauri::menu::{PredefinedMenuItem, Submenu}; @@ -3190,6 +3191,9 @@ pub fn run() { mascot_window_hide, file_logging::reveal_logs_folder, file_logging::logs_folder_path, + workspace_paths::open_workspace_path, + workspace_paths::reveal_workspace_path, + workspace_paths::preview_workspace_text, meet_call::meet_call_open_window, meet_call::meet_call_close_window, companion_commands::register_companion_hotkey, diff --git a/app/src-tauri/src/workspace_paths.rs b/app/src-tauri/src/workspace_paths.rs new file mode 100644 index 000000000..7cbe7b27f --- /dev/null +++ b/app/src-tauri/src/workspace_paths.rs @@ -0,0 +1,418 @@ +use serde::Serialize; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, +}; + +const DEFAULT_PREVIEW_MAX_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceTextPreview { + pub path: String, + pub absolute_path: String, + pub contents: String, + pub truncated: bool, + pub size_bytes: u64, +} + +#[tauri::command] +pub async fn open_workspace_path(path: String) -> Result<(), String> { + let workspace = active_workspace_root().await?; + let target = resolve_workspace_path(&workspace, &path)?; + let workspace_path = workspace_path_label(&workspace, &target); + tauri_plugin_opener::open_path(&target, None::<&str>).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to open workspace path {workspace_path}: {err}"), + format!("failed to open workspace path {}: {err}", target.display()), + ) + }) +} + +#[tauri::command] +pub async fn reveal_workspace_path(path: String) -> Result<(), String> { + let workspace = active_workspace_root().await?; + let target = resolve_workspace_path(&workspace, &path)?; + let workspace_path = workspace_path_label(&workspace, &target); + tauri_plugin_opener::reveal_item_in_dir(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to reveal workspace path {workspace_path}: {err}"), + format!( + "failed to reveal workspace path {}: {err}", + target.display() + ), + ) + }) +} + +#[tauri::command] +pub async fn preview_workspace_text(path: String) -> Result { + let workspace = active_workspace_root().await?; + preview_workspace_text_from_root(&workspace, &path, DEFAULT_PREVIEW_MAX_BYTES) +} + +async fn active_workspace_root() -> Result { + let config = openhuman_core::openhuman::config::Config::load_or_init() + .await + .map_err(|err| workspace_path_error(format!("failed to load OpenHuman config: {err}")))?; + fs::create_dir_all(&config.workspace_dir).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to create workspace directory: {err}"), + format!( + "failed to create workspace directory {}: {err}", + config.workspace_dir.display() + ), + ) + })?; + Ok(config.workspace_dir) +} + +fn workspace_path_error(message: impl Into) -> String { + let message = message.into(); + log::warn!("[workspace-paths] {message}"); + message +} + +fn workspace_path_error_with_debug( + message: impl Into, + debug_message: impl Into, +) -> String { + let message = message.into(); + log::warn!("[workspace-paths] {message}"); + log::debug!("[workspace-paths] {}", debug_message.into()); + message +} + +fn workspace_path_label(workspace_root: &Path, target: &Path) -> String { + let relative = fs::canonicalize(workspace_root) + .ok() + .and_then(|root| target.strip_prefix(root).ok().map(Path::to_path_buf)); + + relative + .as_deref() + .map(path_label) + .or_else(|| { + target + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| "".to_string()) +} + +fn path_label(path: &Path) -> String { + let label = path + .components() + .filter_map(|component| match component { + std::path::Component::Normal(value) => Some(value.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("/"); + + if label.is_empty() { + ".".to_string() + } else { + label + } +} + +fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(workspace_path_error("workspace path must not be empty")); + } + if trimmed.bytes().any(|byte| byte == 0) { + return Err(workspace_path_error( + "workspace path must not contain NUL bytes", + )); + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') + || has_windows_drive_prefix(&normalized) + || has_uri_scheme_prefix(&normalized) + { + return Err(workspace_path_error("workspace path must be relative")); + } + + let mut relative = PathBuf::new(); + let mut clean_parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return Err(workspace_path_error( + "workspace path must stay inside the workspace", + )); + } + relative.push(part); + clean_parts.push(part); + } + + if clean_parts.is_empty() { + return Err(workspace_path_error( + "workspace path must point to a file or directory", + )); + } + + Ok((relative, clean_parts.join("/"))) +} + +fn has_windows_drive_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + +fn has_uri_scheme_prefix(path: &str) -> bool { + let Some((scheme, _)) = path.split_once(':') else { + return false; + }; + let mut bytes = scheme.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + first.is_ascii_alphabetic() + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) +} + +pub(crate) fn resolve_workspace_path( + workspace_root: &Path, + requested_path: &str, +) -> Result { + let (relative, normalized_path) = normalize_workspace_relative_path(requested_path)?; + let root = fs::canonicalize(workspace_root).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to canonicalize workspace directory: {err}"), + format!( + "failed to canonicalize workspace directory {}: {err}", + workspace_root.display() + ), + ) + })?; + let target = root.join(relative); + let target = fs::canonicalize(&target).map_err(|err| { + workspace_path_error(format!( + "workspace path does not exist {normalized_path}: {err}" + )) + })?; + + if !target.starts_with(&root) { + return Err(workspace_path_error_with_debug( + format!("workspace path must stay inside the workspace: {normalized_path}"), + format!( + "workspace path must stay inside the workspace: {} -> {}", + normalized_path, + target.display() + ), + )); + } + + log::debug!( + "[workspace-paths] resolved workspace path: {} -> {}", + normalized_path, + target.display() + ); + Ok(target) +} + +pub(crate) fn preview_workspace_text_from_root( + workspace_root: &Path, + requested_path: &str, + max_bytes: usize, +) -> Result { + let (_, normalized_path) = normalize_workspace_relative_path(requested_path)?; + let target = resolve_workspace_path(workspace_root, &normalized_path)?; + let metadata = fs::metadata(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to read metadata for {normalized_path}: {err}"), + format!("failed to read metadata for {}: {err}", target.display()), + ) + })?; + if !metadata.is_file() { + return Err(workspace_path_error(format!( + "workspace preview target must be a file: {normalized_path}" + ))); + } + + let mut file = fs::File::open(&target).map_err(|err| { + workspace_path_error_with_debug( + format!("failed to open workspace file {normalized_path}: {err}"), + format!("failed to open workspace file {}: {err}", target.display()), + ) + })?; + let mut bytes = Vec::new(); + file.by_ref() + .take(max_bytes.saturating_add(4) as u64) + .read_to_end(&mut bytes) + .map_err(|err| { + workspace_path_error_with_debug( + format!("failed to read workspace file {normalized_path}: {err}"), + format!("failed to read workspace file {}: {err}", target.display()), + ) + })?; + + let truncated = metadata.len() > max_bytes as u64; + let preview_len = bytes.len().min(max_bytes); + let contents = utf8_preview(&bytes[..preview_len], truncated).map_err(|err| { + workspace_path_error_with_debug( + format!("{err}: {normalized_path}"), + format!("{err}: {}", target.display()), + ) + })?; + + log::debug!( + "[workspace-paths] previewed workspace text: {} bytes={} truncated={}", + normalized_path, + metadata.len(), + truncated + ); + + Ok(WorkspaceTextPreview { + path: normalized_path, + absolute_path: target.display().to_string(), + contents, + truncated, + size_bytes: metadata.len(), + }) +} + +fn utf8_preview(bytes: &[u8], truncated: bool) -> Result { + match std::str::from_utf8(bytes) { + Ok(text) => Ok(text.to_string()), + Err(err) if truncated && err.error_len().is_none() => { + Ok(String::from_utf8_lossy(&bytes[..err.valid_up_to()]).into_owned()) + } + Err(_) => Err("workspace preview target is not valid UTF-8 text".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn resolve_workspace_path_accepts_existing_relative_file_inside_workspace() { + let workspace = tempdir().unwrap(); + let docs = workspace.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + let file = docs.join("note.md"); + fs::write(&file, "hello").unwrap(); + + let resolved = resolve_workspace_path(workspace.path(), "docs/note.md").unwrap(); + + assert_eq!(resolved, file.canonicalize().unwrap()); + } + + #[test] + fn resolve_workspace_path_rejects_parent_directory_escape() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "../secret.txt").unwrap_err(); + + assert!(err.contains("workspace"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_rejects_absolute_paths() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "/etc/passwd").unwrap_err(); + + assert!(err.contains("relative"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_rejects_uri_scheme_prefix() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "file://etc/passwd").unwrap_err(); + + assert!(err.contains("relative"), "unexpected error: {err}"); + } + + #[test] + fn resolve_workspace_path_accepts_colons_after_first_segment() { + let workspace = tempdir().unwrap(); + let docs = workspace.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + let file = docs.join("2026:05.md"); + fs::write(&file, "dated").unwrap(); + + let resolved = resolve_workspace_path(workspace.path(), "docs/2026:05.md").unwrap(); + + assert_eq!(resolved, file.canonicalize().unwrap()); + } + + #[test] + fn resolve_workspace_path_errors_do_not_expose_workspace_root() { + let workspace = tempdir().unwrap(); + + let err = resolve_workspace_path(workspace.path(), "docs/missing.md").unwrap_err(); + + assert!(err.contains("docs/missing.md"), "unexpected error: {err}"); + assert!( + !err.contains(&workspace.path().display().to_string()), + "error leaked workspace root: {err}" + ); + } + + #[test] + fn preview_workspace_text_from_root_reads_utf8_text() { + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("readme.md"), "# Hello").unwrap(); + + let preview = + preview_workspace_text_from_root(workspace.path(), "readme.md", 1024).unwrap(); + + assert_eq!(preview.path, "readme.md"); + assert_eq!(preview.contents, "# Hello"); + assert!(!preview.truncated); + assert_eq!(preview.size_bytes, 7); + } + + #[test] + fn preview_workspace_text_from_root_truncates_large_text() { + let workspace = tempdir().unwrap(); + fs::write(workspace.path().join("large.md"), "0123456789").unwrap(); + + let preview = preview_workspace_text_from_root(workspace.path(), "large.md", 4).unwrap(); + + assert_eq!(preview.contents, "0123"); + assert!(preview.truncated); + assert_eq!(preview.size_bytes, 10); + } + + #[test] + fn preview_workspace_text_from_root_errors_do_not_expose_workspace_root() { + let workspace = tempdir().unwrap(); + fs::create_dir_all(workspace.path().join("docs")).unwrap(); + + let err = preview_workspace_text_from_root(workspace.path(), "docs", 1024).unwrap_err(); + + assert!(err.contains("docs"), "unexpected error: {err}"); + assert!( + !err.contains(&workspace.path().display().to_string()), + "error leaked workspace root: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn resolve_workspace_path_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let workspace = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let outside_file = outside.path().join("secret.txt"); + fs::write(&outside_file, "secret").unwrap(); + symlink(&outside_file, workspace.path().join("secret-link")).unwrap(); + + let err = resolve_workspace_path(workspace.path(), "secret-link").unwrap_err(); + + assert!(err.contains("workspace"), "unexpected error: {err}"); + } +} diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 79f041cc1..30e7ca2ae 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -208,31 +208,9 @@ const de5: TranslationMap = { 'settings.developerMenu.composioRouting.title': 'Composio Routing (Direktmodus)', 'settings.developerMenu.composioRouting.desc': 'Bring deinen eigenen Composio API-Schlüssel mit und leite Anrufe direkt an backend.composio.dev weiter', - 'settings.developerMenu.mcpServer.title': 'MCP Server', - 'settings.developerMenu.mcpServer.desc': - 'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman', 'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser', 'settings.developerMenu.integrationTriggers.desc': 'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser', - 'settings.mcpServer.title': 'MCP-Server', - 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Werkzeuge', - 'settings.mcpServer.toolsSectionDesc': - 'Werkzeuge, die über den MCP-Stdio-Server beim Ausführen von openhuman-core mcp bereitgestellt werden', - 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', - 'settings.mcpServer.configSectionDesc': - 'Wähle deinen MCP-Client aus, um das richtige Konfigurations-Snippet zu generieren', - 'settings.mcpServer.copySnippet': 'In die Zwischenablage kopieren', - 'settings.mcpServer.copied': 'Kopiert!', - 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', - 'settings.mcpServer.binaryPathNotFound': - 'OpenHuman-Binärdatei nicht gefunden. Wenn du aus dem Quellcode arbeitest, baue sie mit: cargo build --bin openhuman-core', - 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', - 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', - 'settings.mcpServer.clientCursor': 'Cursor', - 'settings.mcpServer.clientCodex': 'Codex', - 'settings.mcpServer.clientZed': 'Zed', - 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', - 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', 'settings.appearance.menuDesc': 'Wähle hell, dunkel oder passend zu deinem Systemthema', 'settings.mascot.active': 'Aktiv', 'settings.mascot.characterDesc': 'Charakterbeschreibung', diff --git a/app/src/pages/conversations/components/AgentMessageBubble.test.tsx b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx new file mode 100644 index 000000000..d67e583f4 --- /dev/null +++ b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { BubbleMarkdown, TableCellMarkdown } from './AgentMessageBubble'; + +const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), openWorkspacePath: vi.fn() })); + +vi.mock('../../../utils/openUrl', () => ({ openUrl: mocks.openUrl })); + +vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({ + openWorkspacePath: mocks.openWorkspacePath, +})); + +describe('AgentMessageBubble markdown links', () => { + beforeEach(() => { + mocks.openUrl.mockReset(); + mocks.openUrl.mockResolvedValue(undefined); + mocks.openWorkspacePath.mockReset(); + mocks.openWorkspacePath.mockResolvedValue(undefined); + }); + + test('opens allowed external links through the OS URL handler', async () => { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'docs' })); + + await waitFor(() => expect(mocks.openUrl).toHaveBeenCalledWith('https://example.com/docs')); + expect(mocks.openWorkspacePath).not.toHaveBeenCalled(); + }); + + test('opens workspace links through the Tauri workspace path command', async () => { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'summary' })); + + await waitFor(() => + expect(mocks.openWorkspacePath).toHaveBeenCalledWith('memory_tree/content/summary.md') + ); + expect(mocks.openUrl).not.toHaveBeenCalled(); + }); + + test('logs workspace link open failures for diagnostics', async () => { + const error = new Error('missing file'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + mocks.openWorkspacePath.mockRejectedValueOnce(error); + + try { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'summary' })); + + await waitFor(() => + expect(consoleError).toHaveBeenCalledWith('workspace open failed:', error) + ); + } finally { + consoleError.mockRestore(); + } + }); + + test('uses the same workspace link handling inside table cells', async () => { + render(); + + await userEvent.click(screen.getByRole('link', { name: 'note' })); + + await waitFor(() => expect(mocks.openWorkspacePath).toHaveBeenCalledWith('docs/note.md')); + expect(mocks.openUrl).not.toHaveBeenCalled(); + }); + + test('does not open raw file links from markdown', async () => { + render(); + + await userEvent.click(screen.getByText('secret')); + + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(mocks.openWorkspacePath).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/pages/conversations/components/AgentMessageBubble.tsx b/app/src/pages/conversations/components/AgentMessageBubble.tsx index 0a66a4d66..4d08a6892 100644 --- a/app/src/pages/conversations/components/AgentMessageBubble.tsx +++ b/app/src/pages/conversations/components/AgentMessageBubble.tsx @@ -1,8 +1,11 @@ -import Markdown from 'react-markdown'; +import type { ReactNode } from 'react'; +import Markdown, { defaultUrlTransform } from 'react-markdown'; import { OPENHUMAN_LINK_EVENT } from '../../../components/OpenhumanLinkModal'; import { parseMarkdownTable } from '../../../utils/agentMessageBubbles'; import { openUrl } from '../../../utils/openUrl'; +import { openWorkspacePath } from '../../../utils/tauriCommands/workspacePaths'; +import { parseWorkspaceHref } from '../../../utils/workspaceLinks'; import { type AgentBubblePosition, getAgentBubbleChrome, @@ -37,6 +40,34 @@ function OpenhumanLinkPill({ path, label }: { path: string; label: string }) { ); } +function transformMarkdownUrl(url: string): string { + return parseWorkspaceHref(url) ? url : defaultUrlTransform(url); +} + +function MarkdownAnchor({ href, children }: { href?: string; children?: ReactNode }) { + return ( + { + e.preventDefault(); + const workspaceTarget = parseWorkspaceHref(href); + if (workspaceTarget) { + void openWorkspacePath(workspaceTarget.path).catch(err => { + console.error('workspace open failed:', err); + }); + return; + } + if (!href || !isAllowedExternalHref(href)) return; + void openUrl(href).catch(() => { + // Ignore launcher errors from OS URL handler failures. + }); + }} + className="cursor-pointer underline"> + {children} + + ); +} + export function BubbleMarkdown({ content, tone = 'agent', @@ -54,23 +85,7 @@ export function BubbleMarkdown({ className={`text-sm prose prose-sm max-w-none prose-p:my-1 prose-pre:my-2 prose-pre:rounded-lg prose-code:text-xs prose-headings:font-semibold prose-ul:my-0 prose-ol:my-0 prose-li:my-0 ${proseTone} ${ tone === 'user' ? 'prose-pre:bg-white/10' : 'prose-pre:bg-stone-300/50' } [&_ul]:my-0 [&_ol]:my-0 [&_ul]:pl-0 [&_ol]:pl-0 [&_ul]:list-inside [&_ol]:list-inside [&_li]:my-0 [&_li]:pl-0 [&_li_p]:inline [&_li_p]:m-0`}> - ( - { - e.preventDefault(); - if (!href || !isAllowedExternalHref(href)) return; - void openUrl(href).catch(() => { - // Ignore launcher errors from OS URL handler failures. - }); - }} - className="cursor-pointer underline"> - {children} - - ), - }}> + {content} @@ -80,23 +95,7 @@ export function BubbleMarkdown({ export function TableCellMarkdown({ content }: { content: string }) { return ( diff --git a/app/src/utils/tauriCommands/index.ts b/app/src/utils/tauriCommands/index.ts index 02c4cb52b..2ac3998a7 100644 --- a/app/src/utils/tauriCommands/index.ts +++ b/app/src/utils/tauriCommands/index.ts @@ -20,3 +20,4 @@ export * from './accessibility'; export * from './autocomplete'; export * from './voice'; export * from './aboutApp'; +export * from './workspacePaths'; diff --git a/app/src/utils/tauriCommands/workspacePaths.test.ts b/app/src/utils/tauriCommands/workspacePaths.test.ts new file mode 100644 index 000000000..4e5c3f9f8 --- /dev/null +++ b/app/src/utils/tauriCommands/workspacePaths.test.ts @@ -0,0 +1,64 @@ +import { invoke } from '@tauri-apps/api/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { isTauri } from './common'; +import { openWorkspacePath, previewWorkspaceText, revealWorkspacePath } from './workspacePaths'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); +vi.mock('./common', () => ({ isTauri: vi.fn() })); + +describe('tauriCommands/workspacePaths', () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + vi.mocked(isTauri).mockReset(); + vi.mocked(isTauri).mockReturnValue(true); + }); + + test('throws before invoking when not running in Tauri', async () => { + vi.mocked(isTauri).mockReturnValue(false); + + await expect(openWorkspacePath('docs/readme.md')).rejects.toThrow('Not running in Tauri'); + + expect(invoke).not.toHaveBeenCalled(); + }); + + test('invokes open_workspace_path with a workspace-relative path', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + + await openWorkspacePath('memory_tree/content/summary.md'); + + expect(invoke).toHaveBeenCalledWith('open_workspace_path', { + path: 'memory_tree/content/summary.md', + }); + }); + + test('invokes reveal_workspace_path with a workspace-relative path', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + + await revealWorkspacePath('memory_tree/content/summary.md'); + + expect(invoke).toHaveBeenCalledWith('reveal_workspace_path', { + path: 'memory_tree/content/summary.md', + }); + }); + + test('invokes preview_workspace_text and returns preview payload', async () => { + vi.mocked(invoke).mockResolvedValue({ + path: 'docs/readme.md', + absolute_path: '/tmp/workspace/docs/readme.md', + contents: '# Readme', + truncated: false, + size_bytes: 8, + }); + + await expect(previewWorkspaceText('docs/readme.md')).resolves.toEqual({ + path: 'docs/readme.md', + absolutePath: '/tmp/workspace/docs/readme.md', + contents: '# Readme', + truncated: false, + sizeBytes: 8, + }); + + expect(invoke).toHaveBeenCalledWith('preview_workspace_text', { path: 'docs/readme.md' }); + }); +}); diff --git a/app/src/utils/tauriCommands/workspacePaths.ts b/app/src/utils/tauriCommands/workspacePaths.ts new file mode 100644 index 000000000..20b104a02 --- /dev/null +++ b/app/src/utils/tauriCommands/workspacePaths.ts @@ -0,0 +1,47 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { isTauri } from './common'; + +interface RawWorkspaceTextPreview { + path: string; + absolute_path: string; + contents: string; + truncated: boolean; + size_bytes: number; +} + +export interface WorkspaceTextPreview { + path: string; + absolutePath: string; + contents: string; + truncated: boolean; + sizeBytes: number; +} + +function assertTauri() { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } +} + +export async function openWorkspacePath(path: string): Promise { + assertTauri(); + await invoke('open_workspace_path', { path }); +} + +export async function revealWorkspacePath(path: string): Promise { + assertTauri(); + await invoke('reveal_workspace_path', { path }); +} + +export async function previewWorkspaceText(path: string): Promise { + assertTauri(); + const preview = await invoke('preview_workspace_text', { path }); + return { + path: preview.path, + absolutePath: preview.absolute_path, + contents: preview.contents, + truncated: preview.truncated, + sizeBytes: preview.size_bytes, + }; +} diff --git a/app/src/utils/workspaceLinks.test.ts b/app/src/utils/workspaceLinks.test.ts new file mode 100644 index 000000000..78136cfee --- /dev/null +++ b/app/src/utils/workspaceLinks.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest'; + +import { isWorkspaceHref, parseWorkspaceHref } from './workspaceLinks'; + +describe('workspaceLinks', () => { + test('parses workspace: links into normalized workspace-relative paths', () => { + expect(parseWorkspaceHref('workspace:memory_tree/content/Daily%20Note.md')).toEqual({ + path: 'memory_tree/content/Daily Note.md', + }); + expect(parseWorkspaceHref('workspace://memory_tree/content/summary.md')).toEqual({ + path: 'memory_tree/content/summary.md', + }); + expect(parseWorkspaceHref('openhuman-workspace:/docs/readme.md')).toEqual({ + path: 'docs/readme.md', + }); + }); + + test('rejects non-workspace links and traversal payloads', () => { + expect(parseWorkspaceHref('https://example.com/docs')).toBeNull(); + expect(parseWorkspaceHref('file:///etc/passwd')).toBeNull(); + expect(parseWorkspaceHref('workspace:../secret.txt')).toBeNull(); + expect(parseWorkspaceHref('workspace:docs/%2e%2e/secret.txt')).toBeNull(); + expect(parseWorkspaceHref('workspace:docs/%00secret.txt')).toBeNull(); + expect(parseWorkspaceHref('workspace:C:/Users/me/secret.txt')).toBeNull(); + }); + + test('identifies workspace links without allowing unsafe paths', () => { + expect(isWorkspaceHref('workspace:docs/plan.md')).toBe(true); + expect(isWorkspaceHref('workspace:../plan.md')).toBe(false); + expect(isWorkspaceHref('mailto:support@example.com')).toBe(false); + }); +}); diff --git a/app/src/utils/workspaceLinks.ts b/app/src/utils/workspaceLinks.ts new file mode 100644 index 000000000..85a801e3c --- /dev/null +++ b/app/src/utils/workspaceLinks.ts @@ -0,0 +1,38 @@ +export interface WorkspaceLinkTarget { + path: string; +} + +const WORKSPACE_SCHEME_RE = /^(?:workspace|openhuman-workspace):/i; +const WINDOWS_DRIVE_RE = /^[a-z]:\//i; + +export function parseWorkspaceHref(rawHref?: string | null): WorkspaceLinkTarget | null { + if (!rawHref) return null; + const trimmed = rawHref.trim(); + if (!WORKSPACE_SCHEME_RE.test(trimmed)) return null; + + const rawPath = trimmed.replace(WORKSPACE_SCHEME_RE, '').replace(/^\/+/, ''); + if (!rawPath || rawPath.includes('\0')) return null; + + let decoded: string; + try { + decoded = decodeURIComponent(rawPath); + } catch { + return null; + } + if (decoded.includes('\0')) return null; + + const normalized = decoded.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized || WINDOWS_DRIVE_RE.test(normalized)) return null; + + const parts = normalized.split('/').filter(Boolean); + if (parts.length === 0) return null; + if (parts.some(part => part === '.' || part === '..' || part.includes(':'))) { + return null; + } + + return { path: parts.join('/') }; +} + +export function isWorkspaceHref(rawHref?: string | null): boolean { + return parseWorkspaceHref(rawHref) !== null; +} diff --git a/gitbooks/developing/architecture/tauri-shell.md b/gitbooks/developing/architecture/tauri-shell.md index c0d2ddd3d..9df31f4bc 100644 --- a/gitbooks/developing/architecture/tauri-shell.md +++ b/gitbooks/developing/architecture/tauri-shell.md @@ -27,7 +27,6 @@ On macOS, hard exits (Force Quit, `SIGKILL`, renderer crash) can skip normal tea Startup recovery skips when `OPENHUMAN_CORE_REUSE_EXISTING=1` is set (so manual CLI-core reuse still works) and when the CEF `SingletonLock` is held by a live process (so the normal second-instance path can fail without killing the already-running app). The Tauri command `process_diagnostics_list_owned` returns the currently owned process list; the macOS implementation is bundle-scoped, Linux/Windows currently return empty. - ## Tauri shell architecture (`app/src-tauri/`) ### Overview @@ -84,7 +83,6 @@ React (invoke) - HTTP bridge: see the [Core bridge & helpers](#core-bridge-helpers-app-src-tauri) section below - Rust domains (implementation): repo root `src/openhuman/`, `src/core_server/` - ## Tauri IPC commands (`app/src-tauri`) All commands are registered in **`app/src-tauri/src/lib.rs`** inside `tauri::generate_handler![...]` (desktop build). Names below are the **Rust** command names (camelCase in JS via serde where applicable). @@ -97,16 +95,16 @@ All commands are registered in **`app/src-tauri/src/lib.rs`** inside `tauri::gen ### AI configuration (bundled prompts) -| Command | Purpose | -| ---------------------- | -------------------------------------------------------------------------------------------- | +| Command | Purpose | +| ---------------------- | --------------------------------------------------------------------------------------------------------- | | `ai_get_config` | Build `AIPreview` from resolved `SOUL.md` / `TOOLS.md` under bundled or dev `src/openhuman/agent/prompts` | -| `ai_refresh_config` | Same read path as `ai_get_config` (refresh hook) | +| `ai_refresh_config` | Same read path as `ai_get_config` (refresh hook) | | `write_ai_config_file` | Write a single `.md` under repo `src/openhuman/agent/prompts` (dev / safe filename checks) | ### Core JSON-RPC relay -| Command | Purpose | -| ---------------- | -------------------------------------------------------------------------------------------------------------- | +| Command | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------------------------- | | `core_rpc_relay` | Body: `{ method, params?, serviceManaged? }` → forwards to local **`openhuman-core`** HTTP JSON-RPC (`core_rpc.rs`) | Use **`app/src/services/coreRpcClient.ts`** (`callCoreRpc`) from the frontend. @@ -144,11 +142,21 @@ From **`commands/openhuman.rs`** (see source for exact payloads): From **`screen_capture/mod.rs`**. Backs the in-page `getDisplayMedia` shim in `webview_accounts/runtime.js`. Session-gated: the shim must open a session with a live user gesture before enumeration / thumbnail captures succeed. See issue #713 (picker UX) + #812 (session gating). -| Command | Purpose | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `screen_share_begin_session` | Open a 30s session from an account webview, after a `navigator.userActivation.isActive` gesture. Returns `{ token, sources }`. Rate-limited to 10/minute per account. | -| `screen_share_thumbnail` | Capture a single source's thumbnail as base64 PNG. Requires a live token and an `id` that the session was issued for. macOS only; other platforms return an error. | -| `screen_share_finalize_session` | Close the session. Called by the shim on Share or Cancel; safe to call with an unknown/expired token (no-op). | +| Command | Purpose | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `screen_share_begin_session` | Open a 30s session from an account webview, after a `navigator.userActivation.isActive` gesture. Returns `{ token, sources }`. Rate-limited to 10/minute per account. | +| `screen_share_thumbnail` | Capture a single source's thumbnail as base64 PNG. Requires a live token and an `id` that the session was issued for. macOS only; other platforms return an error. | +| `screen_share_finalize_session` | Close the session. Called by the shim on Share or Cancel; safe to call with an unknown/expired token (no-op). | + +### Workspace file links + +From **`workspace_paths.rs`** (closes `#1402`). These commands accept workspace-relative paths only. The shell resolves each path against the active OpenHuman workspace, canonicalizes the target, and rejects traversal, absolute paths, URI-like prefixes, and symlink escapes before opening or reading anything. + +| Command | Purpose | +| ------------------------ | ---------------------------------------------------------------------- | +| `open_workspace_path` | Open an existing workspace file or directory with the OS default app. | +| `reveal_workspace_path` | Reveal an existing workspace file or directory in the OS file manager. | +| `preview_workspace_text` | Read a capped UTF-8 text preview from an existing workspace file. | ### Removed / not present @@ -172,7 +180,6 @@ const result = await invoke("core_rpc_relay", { _See `app/src-tauri/src/lib.rs` for the authoritative list._ - ## Core bridge & helpers (`app/src-tauri`) This document replaces the old “SessionService / SocketService” split. The Tauri crate **does not** embed a duplicate Socket.io server or Telegram client; instead it focuses on **process management** and **HTTP JSON-RPC** to the **`openhuman-core`** binary. diff --git a/src/openhuman/cron/store_tests.rs b/src/openhuman/cron/store_tests.rs index cf5fbf369..bf331c53d 100644 --- a/src/openhuman/cron/store_tests.rs +++ b/src/openhuman/cron/store_tests.rs @@ -79,12 +79,15 @@ fn due_jobs_filters_by_timestamp_and_enabled() { let job = add_job(&config, "* * * * *", "echo due").unwrap(); - let due_now = due_jobs(&config, Utc::now()).unwrap(); - assert!(due_now.is_empty(), "new job should not be due immediately"); + let before_next_run = job.next_run - ChronoDuration::seconds(1); + let due_before_next_run = due_jobs(&config, before_next_run).unwrap(); + assert!( + due_before_next_run.is_empty(), + "job should not be due before its next_run timestamp" + ); - let far_future = Utc::now() + ChronoDuration::days(365); - let due_future = due_jobs(&config, far_future).unwrap(); - assert_eq!(due_future.len(), 1, "job should be due in far future"); + let due_at_next_run = due_jobs(&config, job.next_run).unwrap(); + assert_eq!(due_at_next_run.len(), 1, "job should be due at next_run"); let _ = update_job( &config, @@ -95,7 +98,7 @@ fn due_jobs_filters_by_timestamp_and_enabled() { }, ) .unwrap(); - let due_after_disable = due_jobs(&config, far_future).unwrap(); + let due_after_disable = due_jobs(&config, job.next_run).unwrap(); assert!(due_after_disable.is_empty()); }