From 944dde36079f85badefd9bc7dea39fe2c982bc84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 21 May 2026 01:26:48 -0700 Subject: [PATCH 1/9] Update Product Hunt badges in README --- README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 68501bfca..f34d7a4c1 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,26 @@

- - tinyhumansai%2Fopenhuman | Trendshift - - - OpenHuman - An open source AI harness built with the human in mind | Product Hunt - - - OpenHuman - An open source AI harness built with the human in mind | Product Hunt + + tinyhumansai%2Fopenhuman | Trendshift + + + OpenHuman - An open source AI harness built with the human in mind | Product Hunt + + + OpenHuman - An open source AI harness built with the human in mind | Product Hunt +

- +

+ + OpenHuman - An open source AI harness built with the human in mind | Product Hunt + + + OpenHuman - An open source AI harness built with the human in mind | Product Hunt + +

+ +

OpenHuman is your Personal AI super intelligence. Private, Simple and extremely powerful.

From c8745d8cc87c447dd8b630cfd582a0531e4b1d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 11:10:35 +0800 Subject: [PATCH 2/9] feat(tauri): support workspace file links --- .../permissions/allow-core-process.toml | 10 + app/src-tauri/src/lib.rs | 4 + app/src-tauri/src/workspace_paths.rs | 244 ++++++++++++++++++ .../components/AgentMessageBubble.test.tsx | 60 +++++ .../components/AgentMessageBubble.tsx | 69 +++-- app/src/utils/tauriCommands/index.ts | 1 + .../tauriCommands/workspacePaths.test.ts | 64 +++++ app/src/utils/tauriCommands/workspacePaths.ts | 47 ++++ app/src/utils/workspaceLinks.test.ts | 31 +++ app/src/utils/workspaceLinks.ts | 37 +++ .../developing/architecture/tauri-shell.md | 33 ++- 11 files changed, 552 insertions(+), 48 deletions(-) create mode 100644 app/src-tauri/src/workspace_paths.rs create mode 100644 app/src/pages/conversations/components/AgentMessageBubble.test.tsx create mode 100644 app/src/utils/tauriCommands/workspacePaths.test.ts create mode 100644 app/src/utils/tauriCommands/workspacePaths.ts create mode 100644 app/src/utils/workspaceLinks.test.ts create mode 100644 app/src/utils/workspaceLinks.ts diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 823f46f6f..6b03ae873 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -111,6 +111,16 @@ allow = [ "logs_folder_path", "reveal_logs_folder", + # ========================= + # WORKSPACE FILES + # ========================= + # Opens, reveals, or previews files only after resolving a + # workspace-relative path against the active OpenHuman workspace and + # verifying the canonical target still stays inside it. + "open_workspace_path", + "reveal_workspace_path", + "preview_workspace_text", + # ========================= # GOOGLE MEET # ========================= diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3b62ae265..75043cf11 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ mod webview_accounts; mod webview_apis; mod whatsapp_scanner; mod window_state; +mod workspace_paths; #[cfg(target_os = "macos")] use tauri::menu::{PredefinedMenuItem, Submenu}; @@ -3069,6 +3070,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..ed0ad88a7 --- /dev/null +++ b/app/src-tauri/src/workspace_paths.rs @@ -0,0 +1,244 @@ +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)?; + tauri_plugin_opener::open_path(&target, None::<&str>) + .map_err(|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)?; + tauri_plugin_opener::reveal_item_in_dir(&target).map_err(|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| format!("failed to load OpenHuman config: {err}"))?; + fs::create_dir_all(&config.workspace_dir).map_err(|err| { + format!( + "failed to create workspace directory {}: {err}", + config.workspace_dir.display() + ) + })?; + Ok(config.workspace_dir) +} + +fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("workspace path must not be empty".to_string()); + } + if trimmed.bytes().any(|byte| byte == 0) { + return Err("workspace path must not contain NUL bytes".to_string()); + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') || has_windows_drive_prefix(&normalized) { + return Err("workspace path must be relative".to_string()); + } + + 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 must stay inside the workspace".to_string()); + } + if part.contains(':') { + return Err("workspace path must not contain URI or drive prefixes".to_string()); + } + relative.push(part); + clean_parts.push(part); + } + + if clean_parts.is_empty() { + return Err("workspace path must point to a file or directory".to_string()); + } + + 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'/' +} + +pub(crate) fn resolve_workspace_path( + workspace_root: &Path, + requested_path: &str, +) -> Result { + let (relative, _) = normalize_workspace_relative_path(requested_path)?; + let root = fs::canonicalize(workspace_root).map_err(|err| { + format!( + "failed to canonicalize workspace directory {}: {err}", + workspace_root.display() + ) + })?; + let target = root.join(relative); + let target = fs::canonicalize(&target) + .map_err(|err| format!("workspace path does not exist {}: {err}", target.display()))?; + + if !target.starts_with(&root) { + return Err("workspace path must stay inside the workspace".to_string()); + } + + 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| format!("failed to read metadata for {}: {err}", target.display()))?; + if !metadata.is_file() { + return Err("workspace preview target must be a file".to_string()); + } + + let mut file = fs::File::open(&target) + .map_err(|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| 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)?; + + 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 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); + } + + #[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/pages/conversations/components/AgentMessageBubble.test.tsx b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx new file mode 100644 index 000000000..dbb7e69f0 --- /dev/null +++ b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx @@ -0,0 +1,60 @@ +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('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..b9f117715 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(() => { + // Ignore launcher errors from OS file handler failures. + }); + 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..da076bf13 --- /dev/null +++ b/app/src/utils/workspaceLinks.test.ts @@ -0,0 +1,31 @@ +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: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..e5f959095 --- /dev/null +++ b/app/src/utils/workspaceLinks.ts @@ -0,0 +1,37 @@ +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; + } + + 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..5a674a946 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`**. 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. From e08d5e8e6e14e07047e103a97445f7480f801733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 11:19:44 +0800 Subject: [PATCH 3/9] fix(i18n): add missing German strings --- app/src/lib/i18n/chunks/de-3.ts | 2 ++ app/src/lib/i18n/chunks/de-5.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 8cbb4e8ae..996a81855 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -104,6 +104,8 @@ const de3: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert', + 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c698c292f..61cf627a6 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -501,6 +501,28 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', + 'settings.developerMenu.mcpServer.title': 'MCP-Server', + 'settings.developerMenu.mcpServer.desc': + 'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman', + 'settings.mcpServer.title': 'MCP-Server', + 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Werkzeuge', + 'settings.mcpServer.toolsSectionDesc': + 'Werkzeuge, die über den MCP-stdio-Server bereitgestellt werden, wenn openhuman-core mcp läuft', + 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', + 'settings.mcpServer.configSectionDesc': + 'Wähle deinen MCP-Client aus, um das passende Konfigurationssnippet zu erzeugen', + 'settings.mcpServer.copySnippet': 'In die Zwischenablage kopieren', + 'settings.mcpServer.copied': 'Kopiert!', + 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', + 'settings.mcpServer.binaryPathNotFound': + 'OpenHuman-Binary nicht gefunden. Wenn du aus dem Quellcode startest, baue 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', }; export default de5; From 344d5b4fea929e8f5a17650298ee2f048d1fb1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 11:22:12 +0800 Subject: [PATCH 4/9] fix(tauri): split workspace file permission --- app/src-tauri/capabilities/default.json | 1 + app/src-tauri/permissions/allow-core-process.toml | 10 ---------- .../permissions/allow-workspace-files.toml | 13 +++++++++++++ app/src/utils/workspaceLinks.test.ts | 1 + app/src/utils/workspaceLinks.ts | 1 + gitbooks/developing/architecture/tauri-shell.md | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 app/src-tauri/permissions/allow-workspace-files.toml 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-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 6b03ae873..823f46f6f 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -111,16 +111,6 @@ allow = [ "logs_folder_path", "reveal_logs_folder", - # ========================= - # WORKSPACE FILES - # ========================= - # Opens, reveals, or previews files only after resolving a - # workspace-relative path against the active OpenHuman workspace and - # verifying the canonical target still stays inside it. - "open_workspace_path", - "reveal_workspace_path", - "preview_workspace_text", - # ========================= # GOOGLE MEET # ========================= 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/utils/workspaceLinks.test.ts b/app/src/utils/workspaceLinks.test.ts index da076bf13..78136cfee 100644 --- a/app/src/utils/workspaceLinks.test.ts +++ b/app/src/utils/workspaceLinks.test.ts @@ -20,6 +20,7 @@ describe('workspaceLinks', () => { 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(); }); diff --git a/app/src/utils/workspaceLinks.ts b/app/src/utils/workspaceLinks.ts index e5f959095..85a801e3c 100644 --- a/app/src/utils/workspaceLinks.ts +++ b/app/src/utils/workspaceLinks.ts @@ -19,6 +19,7 @@ export function parseWorkspaceHref(rawHref?: string | null): WorkspaceLinkTarget } catch { return null; } + if (decoded.includes('\0')) return null; const normalized = decoded.replace(/\\/g, '/').replace(/^\/+/, ''); if (!normalized || WINDOWS_DRIVE_RE.test(normalized)) return null; diff --git a/gitbooks/developing/architecture/tauri-shell.md b/gitbooks/developing/architecture/tauri-shell.md index 5a674a946..9df31f4bc 100644 --- a/gitbooks/developing/architecture/tauri-shell.md +++ b/gitbooks/developing/architecture/tauri-shell.md @@ -150,7 +150,7 @@ From **`screen_capture/mod.rs`**. Backs the in-page `getDisplayMedia` shim in `w ### Workspace file links -From **`workspace_paths.rs`**. 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. +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 | | ------------------------ | ---------------------------------------------------------------------- | From 0b2c416eaffbe32abcd839802ae9bbba9a886a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 18:57:39 +0800 Subject: [PATCH 5/9] test: make cron due-jobs boundary deterministic --- src/openhuman/cron/store_tests.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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()); } From 1af1c1ea57886dae2478e93519ebc935fe74c74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 21:54:28 +0800 Subject: [PATCH 6/9] fix: surface workspace link failures --- app/src-tauri/src/workspace_paths.rs | 107 +++++++++++++----- .../components/AgentMessageBubble.test.tsx | 18 +++ .../components/AgentMessageBubble.tsx | 4 +- 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/app/src-tauri/src/workspace_paths.rs b/app/src-tauri/src/workspace_paths.rs index ed0ad88a7..b09cb7a1c 100644 --- a/app/src-tauri/src/workspace_paths.rs +++ b/app/src-tauri/src/workspace_paths.rs @@ -20,8 +20,12 @@ pub struct WorkspaceTextPreview { pub async fn open_workspace_path(path: String) -> Result<(), String> { let workspace = active_workspace_root().await?; let target = resolve_workspace_path(&workspace, &path)?; - tauri_plugin_opener::open_path(&target, None::<&str>) - .map_err(|err| format!("failed to open workspace path {}: {err}", target.display())) + tauri_plugin_opener::open_path(&target, None::<&str>).map_err(|err| { + workspace_path_error(format!( + "failed to open workspace path {}: {err}", + target.display() + )) + }) } #[tauri::command] @@ -29,10 +33,10 @@ pub async fn reveal_workspace_path(path: String) -> Result<(), String> { let workspace = active_workspace_root().await?; let target = resolve_workspace_path(&workspace, &path)?; tauri_plugin_opener::reveal_item_in_dir(&target).map_err(|err| { - format!( + workspace_path_error(format!( "failed to reveal workspace path {}: {err}", target.display() - ) + )) }) } @@ -45,28 +49,36 @@ pub async fn preview_workspace_text(path: String) -> Result Result { let config = openhuman_core::openhuman::config::Config::load_or_init() .await - .map_err(|err| format!("failed to load OpenHuman config: {err}"))?; + .map_err(|err| workspace_path_error(format!("failed to load OpenHuman config: {err}")))?; fs::create_dir_all(&config.workspace_dir).map_err(|err| { - format!( + workspace_path_error(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 normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), String> { let trimmed = path.trim(); if trimmed.is_empty() { - return Err("workspace path must not be empty".to_string()); + return Err(workspace_path_error("workspace path must not be empty")); } if trimmed.bytes().any(|byte| byte == 0) { - return Err("workspace path must not contain NUL bytes".to_string()); + 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) { - return Err("workspace path must be relative".to_string()); + return Err(workspace_path_error("workspace path must be relative")); } let mut relative = PathBuf::new(); @@ -76,17 +88,23 @@ fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), St continue; } if part == ".." { - return Err("workspace path must stay inside the workspace".to_string()); + return Err(workspace_path_error( + "workspace path must stay inside the workspace", + )); } if part.contains(':') { - return Err("workspace path must not contain URI or drive prefixes".to_string()); + return Err(workspace_path_error( + "workspace path must not contain URI or drive prefixes", + )); } relative.push(part); clean_parts.push(part); } if clean_parts.is_empty() { - return Err("workspace path must point to a file or directory".to_string()); + return Err(workspace_path_error( + "workspace path must point to a file or directory", + )); } Ok((relative, clean_parts.join("/"))) @@ -101,21 +119,34 @@ pub(crate) fn resolve_workspace_path( workspace_root: &Path, requested_path: &str, ) -> Result { - let (relative, _) = normalize_workspace_relative_path(requested_path)?; + let (relative, normalized_path) = normalize_workspace_relative_path(requested_path)?; let root = fs::canonicalize(workspace_root).map_err(|err| { - format!( + workspace_path_error(format!( "failed to canonicalize workspace directory {}: {err}", workspace_root.display() - ) + )) })?; let target = root.join(relative); - let target = fs::canonicalize(&target) - .map_err(|err| format!("workspace path does not exist {}: {err}", target.display()))?; + let target = fs::canonicalize(&target).map_err(|err| { + workspace_path_error(format!( + "workspace path does not exist {}: {err}", + target.display() + )) + })?; if !target.starts_with(&root) { - return Err("workspace path must stay inside the workspace".to_string()); + return Err(workspace_path_error(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) } @@ -126,23 +157,47 @@ pub(crate) fn preview_workspace_text_from_root( ) -> 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| format!("failed to read metadata for {}: {err}", target.display()))?; + let metadata = fs::metadata(&target).map_err(|err| { + workspace_path_error(format!( + "failed to read metadata for {}: {err}", + target.display() + )) + })?; if !metadata.is_file() { - return Err("workspace preview target must be a file".to_string()); + return Err(workspace_path_error(format!( + "workspace preview target must be a file: {}", + target.display() + ))); } - let mut file = fs::File::open(&target) - .map_err(|err| format!("failed to open workspace file {}: {err}", target.display()))?; + let mut file = fs::File::open(&target).map_err(|err| { + workspace_path_error(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| format!("failed to read workspace file {}: {err}", target.display()))?; + .map_err(|err| { + workspace_path_error(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)?; + let contents = utf8_preview(&bytes[..preview_len], truncated) + .map_err(|err| workspace_path_error(format!("{err}: {}", target.display())))?; + + log::debug!( + "[workspace-paths] previewed workspace text: {} bytes={} truncated={}", + normalized_path, + metadata.len(), + truncated + ); Ok(WorkspaceTextPreview { path: normalized_path, diff --git a/app/src/pages/conversations/components/AgentMessageBubble.test.tsx b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx index dbb7e69f0..d67e583f4 100644 --- a/app/src/pages/conversations/components/AgentMessageBubble.test.tsx +++ b/app/src/pages/conversations/components/AgentMessageBubble.test.tsx @@ -40,6 +40,24 @@ describe('AgentMessageBubble markdown links', () => { 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(); diff --git a/app/src/pages/conversations/components/AgentMessageBubble.tsx b/app/src/pages/conversations/components/AgentMessageBubble.tsx index b9f117715..4d08a6892 100644 --- a/app/src/pages/conversations/components/AgentMessageBubble.tsx +++ b/app/src/pages/conversations/components/AgentMessageBubble.tsx @@ -52,8 +52,8 @@ function MarkdownAnchor({ href, children }: { href?: string; children?: ReactNod e.preventDefault(); const workspaceTarget = parseWorkspaceHref(href); if (workspaceTarget) { - void openWorkspacePath(workspaceTarget.path).catch(() => { - // Ignore launcher errors from OS file handler failures. + void openWorkspacePath(workspaceTarget.path).catch(err => { + console.error('workspace open failed:', err); }); return; } From 3c7bbe97dc68808522f534c1fcba581e52492e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Fri, 22 May 2026 22:09:17 +0800 Subject: [PATCH 7/9] fix: sanitize workspace path errors --- app/src-tauri/src/workspace_paths.rs | 209 +++++++++++++++++++++------ 1 file changed, 164 insertions(+), 45 deletions(-) diff --git a/app/src-tauri/src/workspace_paths.rs b/app/src-tauri/src/workspace_paths.rs index b09cb7a1c..7cbe7b27f 100644 --- a/app/src-tauri/src/workspace_paths.rs +++ b/app/src-tauri/src/workspace_paths.rs @@ -20,11 +20,12 @@ pub struct WorkspaceTextPreview { 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(format!( - "failed to open workspace path {}: {err}", - target.display() - )) + workspace_path_error_with_debug( + format!("failed to open workspace path {workspace_path}: {err}"), + format!("failed to open workspace path {}: {err}", target.display()), + ) }) } @@ -32,11 +33,15 @@ pub async fn open_workspace_path(path: String) -> Result<(), String> { 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(format!( - "failed to reveal workspace path {}: {err}", - target.display() - )) + workspace_path_error_with_debug( + format!("failed to reveal workspace path {workspace_path}: {err}"), + format!( + "failed to reveal workspace path {}: {err}", + target.display() + ), + ) }) } @@ -51,10 +56,13 @@ async fn active_workspace_root() -> Result { .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(format!( - "failed to create workspace directory {}: {err}", - config.workspace_dir.display() - )) + 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) } @@ -65,6 +73,50 @@ fn workspace_path_error(message: impl Into) -> String { 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() { @@ -77,7 +129,10 @@ fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), St } let normalized = trimmed.replace('\\', "/"); - if normalized.starts_with('/') || has_windows_drive_prefix(&normalized) { + if normalized.starts_with('/') + || has_windows_drive_prefix(&normalized) + || has_uri_scheme_prefix(&normalized) + { return Err(workspace_path_error("workspace path must be relative")); } @@ -92,11 +147,6 @@ fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), St "workspace path must stay inside the workspace", )); } - if part.contains(':') { - return Err(workspace_path_error( - "workspace path must not contain URI or drive prefixes", - )); - } relative.push(part); clean_parts.push(part); } @@ -115,31 +165,48 @@ fn has_windows_drive_prefix(path: &str) -> bool { 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(format!( - "failed to canonicalize workspace directory {}: {err}", - workspace_root.display() - )) + 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 {}: {err}", - target.display() + "workspace path does not exist {normalized_path}: {err}" )) })?; if !target.starts_with(&root) { - return Err(workspace_path_error(format!( - "workspace path must stay inside the workspace: {} -> {}", - normalized_path, - target.display() - ))); + 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!( @@ -158,39 +225,42 @@ pub(crate) fn preview_workspace_text_from_root( 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(format!( - "failed to read metadata for {}: {err}", - target.display() - )) + 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: {}", - target.display() + "workspace preview target must be a file: {normalized_path}" ))); } let mut file = fs::File::open(&target).map_err(|err| { - workspace_path_error(format!( - "failed to open workspace file {}: {err}", - target.display() - )) + 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(format!( - "failed to read workspace file {}: {err}", - target.display() - )) + 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(format!("{err}: {}", target.display())))?; + 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={}", @@ -255,6 +325,41 @@ mod tests { 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(); @@ -281,6 +386,20 @@ mod tests { 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() { From 717d0f67ec72ed737d510e742eae4a11cc67e819 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 22 May 2026 17:46:35 -0700 Subject: [PATCH 8/9] fix(i18n): restore German translations dropped by merge with main The PR originally removed four German translation entries (`subconscious.providerUnavailableTitle`, `subconscious.providerSettings`, `settings.developerMenu.mcpServer.title`, `settings.developerMenu.mcpServer.desc`) that are still present in the English source. After merging main, the i18n coverage test (`locale de defines every English key`) flagged them as missing. Restore the German strings so coverage is satisfied. --- app/src/lib/i18n/chunks/de-3.ts | 2 ++ app/src/lib/i18n/chunks/de-5.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 8cbb4e8ae..996a81855 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -104,6 +104,8 @@ const de3: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert', + 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 7858c7cf5..c8a26af5f 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -208,6 +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', From 592cf9575b3224b74879bea644020049660e6bd5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 22 May 2026 18:05:47 -0700 Subject: [PATCH 9/9] fix(i18n): drop duplicate German MCP-server keys after merging main Latest upstream/main already defines `settings.developerMenu.mcpServer.*` and `settings.mcpServer.*` in de-5.ts. The earlier conflict-resolution re-introduced the same keys near the top of the file, causing TS1117 duplicate-key errors on the PR-merge typecheck. Drop the duplicates and keep main's authoritative copies at the end of the file. --- app/src/lib/i18n/chunks/de-5.ts | 22 ---------------------- 1 file changed, 22 deletions(-) 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',