mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(tauri): support workspace file links (#2476)
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
Steven Enamakel
parent
333bd52842
commit
ae6909f66b
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"updater:default",
|
||||
"allow-core-process",
|
||||
"allow-workspace-files",
|
||||
"allow-app-update"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
@@ -35,6 +35,7 @@ mod webview_apis;
|
||||
mod wechat_scanner;
|
||||
mod whatsapp_scanner;
|
||||
mod window_state;
|
||||
mod workspace_paths;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::menu::{PredefinedMenuItem, Submenu};
|
||||
@@ -3190,6 +3191,9 @@ pub fn run() {
|
||||
mascot_window_hide,
|
||||
file_logging::reveal_logs_folder,
|
||||
file_logging::logs_folder_path,
|
||||
workspace_paths::open_workspace_path,
|
||||
workspace_paths::reveal_workspace_path,
|
||||
workspace_paths::preview_workspace_text,
|
||||
meet_call::meet_call_open_window,
|
||||
meet_call::meet_call_close_window,
|
||||
companion_commands::register_companion_hotkey,
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const DEFAULT_PREVIEW_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkspaceTextPreview {
|
||||
pub path: String,
|
||||
pub absolute_path: String,
|
||||
pub contents: String,
|
||||
pub truncated: bool,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_workspace_path(path: String) -> Result<(), String> {
|
||||
let workspace = active_workspace_root().await?;
|
||||
let target = resolve_workspace_path(&workspace, &path)?;
|
||||
let workspace_path = workspace_path_label(&workspace, &target);
|
||||
tauri_plugin_opener::open_path(&target, None::<&str>).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to open workspace path {workspace_path}: {err}"),
|
||||
format!("failed to open workspace path {}: {err}", target.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_workspace_path(path: String) -> Result<(), String> {
|
||||
let workspace = active_workspace_root().await?;
|
||||
let target = resolve_workspace_path(&workspace, &path)?;
|
||||
let workspace_path = workspace_path_label(&workspace, &target);
|
||||
tauri_plugin_opener::reveal_item_in_dir(&target).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to reveal workspace path {workspace_path}: {err}"),
|
||||
format!(
|
||||
"failed to reveal workspace path {}: {err}",
|
||||
target.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_workspace_text(path: String) -> Result<WorkspaceTextPreview, String> {
|
||||
let workspace = active_workspace_root().await?;
|
||||
preview_workspace_text_from_root(&workspace, &path, DEFAULT_PREVIEW_MAX_BYTES)
|
||||
}
|
||||
|
||||
async fn active_workspace_root() -> Result<PathBuf, String> {
|
||||
let config = openhuman_core::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|err| workspace_path_error(format!("failed to load OpenHuman config: {err}")))?;
|
||||
fs::create_dir_all(&config.workspace_dir).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to create workspace directory: {err}"),
|
||||
format!(
|
||||
"failed to create workspace directory {}: {err}",
|
||||
config.workspace_dir.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(config.workspace_dir)
|
||||
}
|
||||
|
||||
fn workspace_path_error(message: impl Into<String>) -> String {
|
||||
let message = message.into();
|
||||
log::warn!("[workspace-paths] {message}");
|
||||
message
|
||||
}
|
||||
|
||||
fn workspace_path_error_with_debug(
|
||||
message: impl Into<String>,
|
||||
debug_message: impl Into<String>,
|
||||
) -> 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(|| "<redacted>".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::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
if label.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_workspace_relative_path(path: &str) -> Result<(PathBuf, String), String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(workspace_path_error("workspace path must not be empty"));
|
||||
}
|
||||
if trimmed.bytes().any(|byte| byte == 0) {
|
||||
return Err(workspace_path_error(
|
||||
"workspace path must not contain NUL bytes",
|
||||
));
|
||||
}
|
||||
|
||||
let normalized = trimmed.replace('\\', "/");
|
||||
if normalized.starts_with('/')
|
||||
|| has_windows_drive_prefix(&normalized)
|
||||
|| has_uri_scheme_prefix(&normalized)
|
||||
{
|
||||
return Err(workspace_path_error("workspace path must be relative"));
|
||||
}
|
||||
|
||||
let mut relative = PathBuf::new();
|
||||
let mut clean_parts = Vec::new();
|
||||
for part in normalized.split('/') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
return Err(workspace_path_error(
|
||||
"workspace path must stay inside the workspace",
|
||||
));
|
||||
}
|
||||
relative.push(part);
|
||||
clean_parts.push(part);
|
||||
}
|
||||
|
||||
if clean_parts.is_empty() {
|
||||
return Err(workspace_path_error(
|
||||
"workspace path must point to a file or directory",
|
||||
));
|
||||
}
|
||||
|
||||
Ok((relative, clean_parts.join("/")))
|
||||
}
|
||||
|
||||
fn has_windows_drive_prefix(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
|
||||
}
|
||||
|
||||
fn has_uri_scheme_prefix(path: &str) -> bool {
|
||||
let Some((scheme, _)) = path.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let mut bytes = scheme.bytes();
|
||||
let Some(first) = bytes.next() else {
|
||||
return false;
|
||||
};
|
||||
first.is_ascii_alphabetic()
|
||||
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_workspace_path(
|
||||
workspace_root: &Path,
|
||||
requested_path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let (relative, normalized_path) = normalize_workspace_relative_path(requested_path)?;
|
||||
let root = fs::canonicalize(workspace_root).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to canonicalize workspace directory: {err}"),
|
||||
format!(
|
||||
"failed to canonicalize workspace directory {}: {err}",
|
||||
workspace_root.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let target = root.join(relative);
|
||||
let target = fs::canonicalize(&target).map_err(|err| {
|
||||
workspace_path_error(format!(
|
||||
"workspace path does not exist {normalized_path}: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !target.starts_with(&root) {
|
||||
return Err(workspace_path_error_with_debug(
|
||||
format!("workspace path must stay inside the workspace: {normalized_path}"),
|
||||
format!(
|
||||
"workspace path must stay inside the workspace: {} -> {}",
|
||||
normalized_path,
|
||||
target.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[workspace-paths] resolved workspace path: {} -> {}",
|
||||
normalized_path,
|
||||
target.display()
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_workspace_text_from_root(
|
||||
workspace_root: &Path,
|
||||
requested_path: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<WorkspaceTextPreview, String> {
|
||||
let (_, normalized_path) = normalize_workspace_relative_path(requested_path)?;
|
||||
let target = resolve_workspace_path(workspace_root, &normalized_path)?;
|
||||
let metadata = fs::metadata(&target).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to read metadata for {normalized_path}: {err}"),
|
||||
format!("failed to read metadata for {}: {err}", target.display()),
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(workspace_path_error(format!(
|
||||
"workspace preview target must be a file: {normalized_path}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut file = fs::File::open(&target).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to open workspace file {normalized_path}: {err}"),
|
||||
format!("failed to open workspace file {}: {err}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let mut bytes = Vec::new();
|
||||
file.by_ref()
|
||||
.take(max_bytes.saturating_add(4) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("failed to read workspace file {normalized_path}: {err}"),
|
||||
format!("failed to read workspace file {}: {err}", target.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let truncated = metadata.len() > max_bytes as u64;
|
||||
let preview_len = bytes.len().min(max_bytes);
|
||||
let contents = utf8_preview(&bytes[..preview_len], truncated).map_err(|err| {
|
||||
workspace_path_error_with_debug(
|
||||
format!("{err}: {normalized_path}"),
|
||||
format!("{err}: {}", target.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
log::debug!(
|
||||
"[workspace-paths] previewed workspace text: {} bytes={} truncated={}",
|
||||
normalized_path,
|
||||
metadata.len(),
|
||||
truncated
|
||||
);
|
||||
|
||||
Ok(WorkspaceTextPreview {
|
||||
path: normalized_path,
|
||||
absolute_path: target.display().to_string(),
|
||||
contents,
|
||||
truncated,
|
||||
size_bytes: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn utf8_preview(bytes: &[u8], truncated: bool) -> Result<String, String> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(text) => Ok(text.to_string()),
|
||||
Err(err) if truncated && err.error_len().is_none() => {
|
||||
Ok(String::from_utf8_lossy(&bytes[..err.valid_up_to()]).into_owned())
|
||||
}
|
||||
Err(_) => Err("workspace preview target is not valid UTF-8 text".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_accepts_existing_relative_file_inside_workspace() {
|
||||
let workspace = tempdir().unwrap();
|
||||
let docs = workspace.path().join("docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
let file = docs.join("note.md");
|
||||
fs::write(&file, "hello").unwrap();
|
||||
|
||||
let resolved = resolve_workspace_path(workspace.path(), "docs/note.md").unwrap();
|
||||
|
||||
assert_eq!(resolved, file.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_rejects_parent_directory_escape() {
|
||||
let workspace = tempdir().unwrap();
|
||||
|
||||
let err = resolve_workspace_path(workspace.path(), "../secret.txt").unwrap_err();
|
||||
|
||||
assert!(err.contains("workspace"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_rejects_absolute_paths() {
|
||||
let workspace = tempdir().unwrap();
|
||||
|
||||
let err = resolve_workspace_path(workspace.path(), "/etc/passwd").unwrap_err();
|
||||
|
||||
assert!(err.contains("relative"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_rejects_uri_scheme_prefix() {
|
||||
let workspace = tempdir().unwrap();
|
||||
|
||||
let err = resolve_workspace_path(workspace.path(), "file://etc/passwd").unwrap_err();
|
||||
|
||||
assert!(err.contains("relative"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_accepts_colons_after_first_segment() {
|
||||
let workspace = tempdir().unwrap();
|
||||
let docs = workspace.path().join("docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
let file = docs.join("2026:05.md");
|
||||
fs::write(&file, "dated").unwrap();
|
||||
|
||||
let resolved = resolve_workspace_path(workspace.path(), "docs/2026:05.md").unwrap();
|
||||
|
||||
assert_eq!(resolved, file.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workspace_path_errors_do_not_expose_workspace_root() {
|
||||
let workspace = tempdir().unwrap();
|
||||
|
||||
let err = resolve_workspace_path(workspace.path(), "docs/missing.md").unwrap_err();
|
||||
|
||||
assert!(err.contains("docs/missing.md"), "unexpected error: {err}");
|
||||
assert!(
|
||||
!err.contains(&workspace.path().display().to_string()),
|
||||
"error leaked workspace root: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_workspace_text_from_root_reads_utf8_text() {
|
||||
let workspace = tempdir().unwrap();
|
||||
fs::write(workspace.path().join("readme.md"), "# Hello").unwrap();
|
||||
|
||||
let preview =
|
||||
preview_workspace_text_from_root(workspace.path(), "readme.md", 1024).unwrap();
|
||||
|
||||
assert_eq!(preview.path, "readme.md");
|
||||
assert_eq!(preview.contents, "# Hello");
|
||||
assert!(!preview.truncated);
|
||||
assert_eq!(preview.size_bytes, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_workspace_text_from_root_truncates_large_text() {
|
||||
let workspace = tempdir().unwrap();
|
||||
fs::write(workspace.path().join("large.md"), "0123456789").unwrap();
|
||||
|
||||
let preview = preview_workspace_text_from_root(workspace.path(), "large.md", 4).unwrap();
|
||||
|
||||
assert_eq!(preview.contents, "0123");
|
||||
assert!(preview.truncated);
|
||||
assert_eq!(preview.size_bytes, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_workspace_text_from_root_errors_do_not_expose_workspace_root() {
|
||||
let workspace = tempdir().unwrap();
|
||||
fs::create_dir_all(workspace.path().join("docs")).unwrap();
|
||||
|
||||
let err = preview_workspace_text_from_root(workspace.path(), "docs", 1024).unwrap_err();
|
||||
|
||||
assert!(err.contains("docs"), "unexpected error: {err}");
|
||||
assert!(
|
||||
!err.contains(&workspace.path().display().to_string()),
|
||||
"error leaked workspace root: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_workspace_path_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = tempdir().unwrap();
|
||||
let outside = tempdir().unwrap();
|
||||
let outside_file = outside.path().join("secret.txt");
|
||||
fs::write(&outside_file, "secret").unwrap();
|
||||
symlink(&outside_file, workspace.path().join("secret-link")).unwrap();
|
||||
|
||||
let err = resolve_workspace_path(workspace.path(), "secret-link").unwrap_err();
|
||||
|
||||
assert!(err.contains("workspace"), "unexpected error: {err}");
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { BubbleMarkdown, TableCellMarkdown } from './AgentMessageBubble';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), openWorkspacePath: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: mocks.openUrl }));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({
|
||||
openWorkspacePath: mocks.openWorkspacePath,
|
||||
}));
|
||||
|
||||
describe('AgentMessageBubble markdown links', () => {
|
||||
beforeEach(() => {
|
||||
mocks.openUrl.mockReset();
|
||||
mocks.openUrl.mockResolvedValue(undefined);
|
||||
mocks.openWorkspacePath.mockReset();
|
||||
mocks.openWorkspacePath.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('opens allowed external links through the OS URL handler', async () => {
|
||||
render(<BubbleMarkdown content="[docs](https://example.com/docs)" />);
|
||||
|
||||
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(<BubbleMarkdown content="[summary](workspace:memory_tree/content/summary.md)" />);
|
||||
|
||||
await userEvent.click(screen.getByRole('link', { name: 'summary' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.openWorkspacePath).toHaveBeenCalledWith('memory_tree/content/summary.md')
|
||||
);
|
||||
expect(mocks.openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logs workspace link open failures for diagnostics', async () => {
|
||||
const error = new Error('missing file');
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mocks.openWorkspacePath.mockRejectedValueOnce(error);
|
||||
|
||||
try {
|
||||
render(<BubbleMarkdown content="[summary](workspace:memory_tree/content/missing.md)" />);
|
||||
|
||||
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(<TableCellMarkdown content="[note](openhuman-workspace:/docs/note.md)" />);
|
||||
|
||||
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(<BubbleMarkdown content="[secret](file:///etc/passwd)" />);
|
||||
|
||||
await userEvent.click(screen.getByText('secret'));
|
||||
|
||||
expect(mocks.openUrl).not.toHaveBeenCalled();
|
||||
expect(mocks.openWorkspacePath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
const workspaceTarget = parseWorkspaceHref(href);
|
||||
if (workspaceTarget) {
|
||||
void openWorkspacePath(workspaceTarget.path).catch(err => {
|
||||
console.error('workspace open failed:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!href || !isAllowedExternalHref(href)) return;
|
||||
void openUrl(href).catch(() => {
|
||||
// Ignore launcher errors from OS URL handler failures.
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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`}>
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (!href || !isAllowedExternalHref(href)) return;
|
||||
void openUrl(href).catch(() => {
|
||||
// Ignore launcher errors from OS URL handler failures.
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}>
|
||||
<Markdown urlTransform={transformMarkdownUrl} components={{ a: MarkdownAnchor }}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
@@ -80,23 +95,7 @@ export function BubbleMarkdown({
|
||||
export function TableCellMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm text-stone-700 dark:text-neutral-200 prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-code:text-xs prose-code:text-primary-700 dark:prose-code:text-primary-300 prose-a:text-primary-500 prose-strong:text-stone-900 dark:prose-strong:text-neutral-100 prose-headings:text-sm prose-headings:font-semibold [&_li::marker]:text-stone-700 dark:[&_li::marker]:text-neutral-300 [&_ul]:my-0 [&_ol]:my-0 [&_ul]:pl-0 [&_ol]:pl-0 [&_ul]:list-inside [&_ol]:list-inside [&_li]:pl-0 [&_li_p]:inline [&_li_p]:m-0">
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (!href || !isAllowedExternalHref(href)) return;
|
||||
void openUrl(href).catch(() => {
|
||||
// Ignore launcher errors from OS URL handler failures.
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}>
|
||||
<Markdown urlTransform={transformMarkdownUrl} components={{ a: MarkdownAnchor }}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from './accessibility';
|
||||
export * from './autocomplete';
|
||||
export * from './voice';
|
||||
export * from './aboutApp';
|
||||
export * from './workspacePaths';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
assertTauri();
|
||||
await invoke<void>('open_workspace_path', { path });
|
||||
}
|
||||
|
||||
export async function revealWorkspacePath(path: string): Promise<void> {
|
||||
assertTauri();
|
||||
await invoke<void>('reveal_workspace_path', { path });
|
||||
}
|
||||
|
||||
export async function previewWorkspaceText(path: string): Promise<WorkspaceTextPreview> {
|
||||
assertTauri();
|
||||
const preview = await invoke<RawWorkspaceTextPreview>('preview_workspace_text', { path });
|
||||
return {
|
||||
path: preview.path,
|
||||
absolutePath: preview.absolute_path,
|
||||
contents: preview.contents,
|
||||
truncated: preview.truncated,
|
||||
sizeBytes: preview.size_bytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isWorkspaceHref, parseWorkspaceHref } from './workspaceLinks';
|
||||
|
||||
describe('workspaceLinks', () => {
|
||||
test('parses workspace: links into normalized workspace-relative paths', () => {
|
||||
expect(parseWorkspaceHref('workspace:memory_tree/content/Daily%20Note.md')).toEqual({
|
||||
path: 'memory_tree/content/Daily Note.md',
|
||||
});
|
||||
expect(parseWorkspaceHref('workspace://memory_tree/content/summary.md')).toEqual({
|
||||
path: 'memory_tree/content/summary.md',
|
||||
});
|
||||
expect(parseWorkspaceHref('openhuman-workspace:/docs/readme.md')).toEqual({
|
||||
path: 'docs/readme.md',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-workspace links and traversal payloads', () => {
|
||||
expect(parseWorkspaceHref('https://example.com/docs')).toBeNull();
|
||||
expect(parseWorkspaceHref('file:///etc/passwd')).toBeNull();
|
||||
expect(parseWorkspaceHref('workspace:../secret.txt')).toBeNull();
|
||||
expect(parseWorkspaceHref('workspace:docs/%2e%2e/secret.txt')).toBeNull();
|
||||
expect(parseWorkspaceHref('workspace:docs/%00secret.txt')).toBeNull();
|
||||
expect(parseWorkspaceHref('workspace:C:/Users/me/secret.txt')).toBeNull();
|
||||
});
|
||||
|
||||
test('identifies workspace links without allowing unsafe paths', () => {
|
||||
expect(isWorkspaceHref('workspace:docs/plan.md')).toBe(true);
|
||||
expect(isWorkspaceHref('workspace:../plan.md')).toBe(false);
|
||||
expect(isWorkspaceHref('mailto:support@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface WorkspaceLinkTarget {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_SCHEME_RE = /^(?:workspace|openhuman-workspace):/i;
|
||||
const WINDOWS_DRIVE_RE = /^[a-z]:\//i;
|
||||
|
||||
export function parseWorkspaceHref(rawHref?: string | null): WorkspaceLinkTarget | null {
|
||||
if (!rawHref) return null;
|
||||
const trimmed = rawHref.trim();
|
||||
if (!WORKSPACE_SCHEME_RE.test(trimmed)) return null;
|
||||
|
||||
const rawPath = trimmed.replace(WORKSPACE_SCHEME_RE, '').replace(/^\/+/, '');
|
||||
if (!rawPath || rawPath.includes('\0')) return null;
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded.includes('\0')) return null;
|
||||
|
||||
const normalized = decoded.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!normalized || WINDOWS_DRIVE_RE.test(normalized)) return null;
|
||||
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.some(part => part === '.' || part === '..' || part.includes(':'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: parts.join('/') };
|
||||
}
|
||||
|
||||
export function isWorkspaceHref(rawHref?: string | null): boolean {
|
||||
return parseWorkspaceHref(rawHref) !== null;
|
||||
}
|
||||
@@ -27,7 +27,6 @@ On macOS, hard exits (Force Quit, `SIGKILL`, renderer crash) can skip normal tea
|
||||
|
||||
Startup recovery skips when `OPENHUMAN_CORE_REUSE_EXISTING=1` is set (so manual CLI-core reuse still works) and when the CEF `SingletonLock` is held by a live process (so the normal second-instance path can fail without killing the already-running app). The Tauri command `process_diagnostics_list_owned` returns the currently owned process list; the macOS implementation is bundle-scoped, Linux/Windows currently return empty.
|
||||
|
||||
|
||||
## Tauri shell architecture (`app/src-tauri/`)
|
||||
|
||||
### Overview
|
||||
@@ -84,7 +83,6 @@ React (invoke)
|
||||
- HTTP bridge: see the [Core bridge & helpers](#core-bridge-helpers-app-src-tauri) section below
|
||||
- Rust domains (implementation): repo root `src/openhuman/`, `src/core_server/`
|
||||
|
||||
|
||||
## Tauri IPC commands (`app/src-tauri`)
|
||||
|
||||
All commands are registered in **`app/src-tauri/src/lib.rs`** inside `tauri::generate_handler![...]` (desktop build). Names below are the **Rust** command names (camelCase in JS via serde where applicable).
|
||||
@@ -97,16 +95,16 @@ All commands are registered in **`app/src-tauri/src/lib.rs`** inside `tauri::gen
|
||||
|
||||
### AI configuration (bundled prompts)
|
||||
|
||||
| Command | Purpose |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Command | Purpose |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ai_get_config` | Build `AIPreview` from resolved `SOUL.md` / `TOOLS.md` under bundled or dev `src/openhuman/agent/prompts` |
|
||||
| `ai_refresh_config` | Same read path as `ai_get_config` (refresh hook) |
|
||||
| `ai_refresh_config` | Same read path as `ai_get_config` (refresh hook) |
|
||||
| `write_ai_config_file` | Write a single `.md` under repo `src/openhuman/agent/prompts` (dev / safe filename checks) |
|
||||
|
||||
### Core JSON-RPC relay
|
||||
|
||||
| Command | Purpose |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Command | Purpose |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `core_rpc_relay` | Body: `{ method, params?, serviceManaged? }` → forwards to local **`openhuman-core`** HTTP JSON-RPC (`core_rpc.rs`) |
|
||||
|
||||
Use **`app/src/services/coreRpcClient.ts`** (`callCoreRpc`) from the frontend.
|
||||
@@ -144,11 +142,21 @@ From **`commands/openhuman.rs`** (see source for exact payloads):
|
||||
|
||||
From **`screen_capture/mod.rs`**. Backs the in-page `getDisplayMedia` shim in `webview_accounts/runtime.js`. Session-gated: the shim must open a session with a live user gesture before enumeration / thumbnail captures succeed. See issue #713 (picker UX) + #812 (session gating).
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `screen_share_begin_session` | Open a 30s session from an account webview, after a `navigator.userActivation.isActive` gesture. Returns `{ token, sources }`. Rate-limited to 10/minute per account. |
|
||||
| `screen_share_thumbnail` | Capture a single source's thumbnail as base64 PNG. Requires a live token and an `id` that the session was issued for. macOS only; other platforms return an error. |
|
||||
| `screen_share_finalize_session` | Close the session. Called by the shim on Share or Cancel; safe to call with an unknown/expired token (no-op). |
|
||||
| Command | Purpose |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `screen_share_begin_session` | Open a 30s session from an account webview, after a `navigator.userActivation.isActive` gesture. Returns `{ token, sources }`. Rate-limited to 10/minute per account. |
|
||||
| `screen_share_thumbnail` | Capture a single source's thumbnail as base64 PNG. Requires a live token and an `id` that the session was issued for. macOS only; other platforms return an error. |
|
||||
| `screen_share_finalize_session` | Close the session. Called by the shim on Share or Cancel; safe to call with an unknown/expired token (no-op). |
|
||||
|
||||
### Workspace file links
|
||||
|
||||
From **`workspace_paths.rs`** (closes `#1402`). These commands accept workspace-relative paths only. The shell resolves each path against the active OpenHuman workspace, canonicalizes the target, and rejects traversal, absolute paths, URI-like prefixes, and symlink escapes before opening or reading anything.
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------------------------- |
|
||||
| `open_workspace_path` | Open an existing workspace file or directory with the OS default app. |
|
||||
| `reveal_workspace_path` | Reveal an existing workspace file or directory in the OS file manager. |
|
||||
| `preview_workspace_text` | Read a capped UTF-8 text preview from an existing workspace file. |
|
||||
|
||||
### Removed / not present
|
||||
|
||||
@@ -172,7 +180,6 @@ const result = await invoke("core_rpc_relay", {
|
||||
|
||||
_See `app/src-tauri/src/lib.rs` for the authoritative list._
|
||||
|
||||
|
||||
## Core bridge & helpers (`app/src-tauri`)
|
||||
|
||||
This document replaces the old “SessionService / SocketService” split. The Tauri crate **does not** embed a duplicate Socket.io server or Telegram client; instead it focuses on **process management** and **HTTP JSON-RPC** to the **`openhuman-core`** binary.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user