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 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
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',