diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index 253637f43..4d94eef81 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { + type AgentPaths, type AutonomyLevel, isTauri, + openhumanGetAgentPaths, openhumanGetAgentSettings, openhumanGetAutonomySettings, openhumanUpdateAgentSettings, @@ -77,6 +79,10 @@ const AgentAccessPanel = () => { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [savedNote, setSavedNote] = useState(null); + // Live agent filesystem roots fetched from the core. `null` while the + // RPC is pending or when not running under Tauri — the JSX falls back to + // the documented defaults so the section never renders empty. + const [agentPaths, setAgentPaths] = useState(null); // Monotonic guard so out-of-order auto-save responses can't clobber UI state // with a stale result (last write wins). const persistSeqRef = useRef(0); @@ -99,8 +105,6 @@ const AgentAccessPanel = () => { } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : t('settings.agentAccess.loadError')); - } finally { - if (!cancelled) setIsLoading(false); } try { const agentResp = await openhumanGetAgentSettings(); @@ -114,6 +118,16 @@ const AgentAccessPanel = () => { // Non-fatal: autonomy controls still render; timeout section // stays at defaults and the user can try saving manually. } + try { + const pathsResp = await openhumanGetAgentPaths(); + if (cancelled) return; + setAgentPaths(pathsResp.result); + } catch { + // Non-fatal: the Directories section falls back to the documented + // defaults below. We don't gate the rest of the panel on this. + } finally { + if (!cancelled) setIsLoading(false); + } }; void load(); return () => { @@ -335,8 +349,10 @@ const AgentAccessPanel = () => { {t('settings.agentAccess.readWriteAccess')} -

- ~/OpenHuman/projects +

+ {agentPaths?.action_dir ?? '~/OpenHuman/projects'}

{t('settings.agentAccess.actionSandboxDesc')} @@ -352,8 +368,10 @@ const AgentAccessPanel = () => { {t('settings.agentAccess.agentBlocked')} -

- ~/.openhuman/workspace +

+ {agentPaths?.workspace_dir ?? '~/.openhuman/workspace'}

{t('settings.agentAccess.internalStateDesc')} diff --git a/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx b/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx index 7cf40742d..4f9ecdebf 100644 --- a/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx @@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '../../../../test/test-utils'; import { + type AgentPaths, type AgentSettings, type AutonomySettings, isTauri, + openhumanGetAgentPaths, openhumanGetAgentSettings, openhumanGetAutonomySettings, openhumanUpdateAgentSettings, @@ -34,6 +36,13 @@ const agentSettings = (overrides: Partial = {}): AgentSettings => ...overrides, }); +const agentPaths = (overrides: Partial = {}): AgentPaths => ({ + action_dir: '/home/test/OpenHuman/projects', + workspace_dir: '/home/test/.openhuman/users/u1/workspace', + projects_dir: '/home/test/OpenHuman/projects', + ...overrides, +}); + vi.mock('../../hooks/useSettingsNavigation', () => ({ useSettingsNavigation: () => ({ navigateBack: vi.fn(), @@ -53,6 +62,7 @@ vi.mock('../../../../utils/tauriCommands', async () => { openhumanUpdateAutonomySettings: vi.fn(), openhumanGetAgentSettings: vi.fn(), openhumanUpdateAgentSettings: vi.fn(), + openhumanGetAgentPaths: vi.fn(), }; }); @@ -60,6 +70,7 @@ const mockGet = vi.mocked(openhumanGetAutonomySettings); const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); const mockGetAgent = vi.mocked(openhumanGetAgentSettings); const mockUpdateAgent = vi.mocked(openhumanUpdateAgentSettings); +const mockGetAgentPaths = vi.mocked(openhumanGetAgentPaths); describe('AgentAccessPanel', () => { beforeEach(() => { @@ -69,6 +80,7 @@ describe('AgentAccessPanel', () => { mockUpdate.mockResolvedValue({ result: {} as never, logs: [] }); mockGetAgent.mockResolvedValue({ result: agentSettings(), logs: [] }); mockUpdateAgent.mockResolvedValue({ result: {} as never, logs: [] }); + mockGetAgentPaths.mockResolvedValue({ result: agentPaths(), logs: [] }); }); it('loads settings on mount and renders the three access tiers', async () => { @@ -226,4 +238,56 @@ describe('AgentAccessPanel', () => { expect(input.disabled).toBe(true); expect(screen.getByText(/OPENHUMAN_TOOL_TIMEOUT_SECS/)).toBeInTheDocument(); }); + + // ── Directories section (#3237) ─────────────────────────────────────────── + + it('renders the live action_dir and workspace_dir returned by the core', async () => { + mockGetAgentPaths.mockResolvedValue({ + result: agentPaths({ + action_dir: '/Users/sample/OpenHuman/projects', + workspace_dir: '/Users/sample/.openhuman/users/u1/workspace', + }), + logs: [], + }); + renderWithProviders(); + await waitFor(() => expect(mockGetAgentPaths).toHaveBeenCalledTimes(1)); + expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent( + '/Users/sample/OpenHuman/projects' + ); + expect(screen.getByTestId('agent-access-workspace-dir')).toHaveTextContent( + '/Users/sample/.openhuman/users/u1/workspace' + ); + }); + + it('reflects an OPENHUMAN_ACTION_DIR override in the action sandbox row', async () => { + // When the operator sets OPENHUMAN_ACTION_DIR, the core's get_agent_paths + // returns the override value as `action_dir`. The panel must render that + // verbatim instead of the hard-coded `~/OpenHuman/projects` default — + // otherwise Settings actively misleads about where the agent writes. + mockGetAgentPaths.mockResolvedValue({ + result: agentPaths({ + action_dir: '/tmp/custom-actions', + projects_dir: '/Users/sample/OpenHuman/projects', + }), + logs: [], + }); + renderWithProviders(); + expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent( + '/tmp/custom-actions' + ); + }); + + it('falls back to documented defaults when the agent paths RPC fails', async () => { + // Non-fatal failure path: the rest of the panel must still render and + // the Directories rows must show the documented default strings so the + // section never appears empty. + mockGetAgentPaths.mockRejectedValue(new Error('rpc unavailable')); + renderWithProviders(); + expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent( + '~/OpenHuman/projects' + ); + expect(screen.getByTestId('agent-access-workspace-dir')).toHaveTextContent( + '~/.openhuman/workspace' + ); + }); }); diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index 63d58338c..0d04cbbbc 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -1,5 +1,6 @@ export const CORE_RPC_METHODS = { configGet: 'openhuman.config_get', + configGetAgentPaths: 'openhuman.config_get_agent_paths', configGetAgentSettings: 'openhuman.config_get_agent_settings', configGetAnalyticsSettings: 'openhuman.config_get_analytics_settings', configGetAutonomySettings: 'openhuman.config_get_autonomy_settings', diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index f380bee9c..8ed145179 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -345,6 +345,33 @@ export async function openhumanGetAutonomySettings(): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configGetAgentPaths, + }); +} + export async function openhumanUpdateAutonomySettings( update: AutonomySettingsUpdate ): Promise> { diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 60b218849..390a4b6b9 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -1938,6 +1938,44 @@ pub async fn get_data_paths() -> Result, String> { )) } +/// Reports the agent's filesystem roots so the UI can render them live +/// instead of hard-coding strings that drift away from `Config`. +/// +/// Returns three string paths: +/// +/// * `action_dir` — the agent's read/write root (`Config.action_dir`). +/// Defaults to `default_action_dir()` (`~/OpenHuman/projects` via +/// `default_projects_dir()`); overridable via `OPENHUMAN_ACTION_DIR`. +/// Acting tools (`shell`, `node_exec`, `npm_exec`, `file_write`, +/// `edit_file`, `apply_patch`, `git_operations`) default their CWD here. +/// * `workspace_dir` — internal product state (`Config.workspace_dir`, +/// typically `~/.openhuman/users//workspace`). Agent-blocked via +/// [`SecurityPolicy::is_workspace_internal_path`]. +/// * `projects_dir` — the default projects home +/// (`default_projects_dir()`, `~/OpenHuman/projects`), injected as a +/// ReadWrite trusted root at startup. Same as `action_dir` when the +/// user hasn't set `OPENHUMAN_ACTION_DIR`. +/// +/// Distinct from [`get_data_paths`], which reports the `openhuman_dir` +/// roots that `reset_local_data` would remove and is consumed only by +/// the Tauri reset flow. +pub async fn get_agent_paths() -> Result, String> { + let config = load_config_with_timeout().await?; + let projects_dir = crate::openhuman::config::default_projects_dir(); + Ok(RpcOutcome::new( + json!({ + "action_dir": config.action_dir.display().to_string(), + "workspace_dir": config.workspace_dir.display().to_string(), + "projects_dir": projects_dir.display().to_string(), + }), + vec![format!( + "agent paths resolved (action={}, workspace={})", + config.action_dir.display(), + config.workspace_dir.display(), + )], + )) +} + #[cfg(test)] #[path = "ops_tests.rs"] mod tests; diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index f2cf3aeaa..46e4aaea7 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -257,6 +257,7 @@ pub fn all_controller_schemas() -> Vec { schemas("agent_server_status"), schemas("reset_local_data"), schemas("get_data_paths"), + schemas("get_agent_paths"), schemas("get_onboarding_completed"), schemas("set_onboarding_completed"), schemas("get_dictation_settings"), @@ -362,6 +363,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("get_data_paths"), handler: handle_get_data_paths, }, + RegisteredController { + schema: schemas("get_agent_paths"), + handler: handle_get_agent_paths, + }, RegisteredController { schema: schemas("get_onboarding_completed"), handler: handle_get_onboarding_completed, @@ -968,6 +973,17 @@ pub fn schemas(function: &str) -> ControllerSchema { "Resolved data paths: current_openhuman_dir, default_openhuman_dir, active_workspace_marker_path.", )], }, + "get_agent_paths" => ControllerSchema { + namespace: "config", + function: "get_agent_paths", + description: + "Resolve the agent's filesystem roots (action_dir, workspace_dir, projects_dir) so the UI can render live values instead of hard-coded strings. Read-only.", + inputs: vec![], + outputs: vec![json_output( + "paths", + "Resolved agent paths: action_dir (acting-tool CWD), workspace_dir (internal state, agent-blocked), projects_dir (default projects home).", + )], + }, "get_onboarding_completed" => ControllerSchema { namespace: "config", function: "get_onboarding_completed", @@ -1545,6 +1561,22 @@ fn handle_get_data_paths(_params: Map) -> ControllerFuture { }) } +fn handle_get_agent_paths(_params: Map) -> ControllerFuture { + Box::pin(async { + log::debug!("[config][rpc] get_agent_paths enter"); + match config_rpc::get_agent_paths().await { + Ok(outcome) => { + log::debug!("[config][rpc] get_agent_paths ok"); + to_json(outcome) + } + Err(err) => { + log::warn!("[config][rpc] get_agent_paths fail: {err}"); + Err(err) + } + } + }) +} + fn handle_get_onboarding_completed(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) }) } diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index a2aa6e80b..6a4df22e4 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -352,3 +352,104 @@ async fn handle_update_autonomy_settings_rejects_invalid_value() { std::env::remove_var("OPENHUMAN_WORKSPACE"); } } + +// ── agent paths handler (#3237) ────────────────────────────── + +#[test] +fn agent_paths_rpc_is_registered() { + let funcs: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.function) + .collect(); + assert!( + funcs.contains(&"get_agent_paths"), + "get_agent_paths must be registered for the AgentAccessPanel to read live paths (#3237)" + ); +} + +#[tokio::test] +async fn handle_get_agent_paths_returns_action_workspace_and_projects() { + // Regression guard for #3237. AgentAccessPanel calls this RPC to render + // the action sandbox / internal workspace paths instead of the hard-coded + // `~/OpenHuman/projects` / `~/.openhuman/workspace` strings that drift + // when an operator sets OPENHUMAN_ACTION_DIR. + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + + let out = super::handle_get_agent_paths(serde_json::Map::new()) + .await + .expect("get_agent_paths handler must succeed"); + // into_cli_compatible_json wraps data under "result" when logs are present. + let inner = out.get("result").unwrap_or(&out); + + let action_dir = inner + .get("action_dir") + .and_then(|v| v.as_str()) + .expect("action_dir field must be a string"); + let workspace_dir = inner + .get("workspace_dir") + .and_then(|v| v.as_str()) + .expect("workspace_dir field must be a string"); + let projects_dir = inner + .get("projects_dir") + .and_then(|v| v.as_str()) + .expect("projects_dir field must be a string"); + + assert!( + !action_dir.is_empty(), + "action_dir must resolve to a non-empty path" + ); + assert!( + !workspace_dir.is_empty(), + "workspace_dir must resolve to a non-empty path" + ); + assert!( + !projects_dir.is_empty(), + "projects_dir must resolve to a non-empty path" + ); + + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } +} + +#[tokio::test] +async fn handle_get_agent_paths_reflects_openhuman_action_dir_env_override() { + // #3237 acceptance criterion: setting OPENHUMAN_ACTION_DIR and restarting + // must show that override in the panel. The override is honoured by + // default_action_dir() at Config load time; this test verifies the RPC + // surface forwards the loaded value unchanged. + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let custom_actions = tmp.path().join("custom-actions-3237"); + std::fs::create_dir_all(&custom_actions).expect("create custom action dir"); + + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + std::env::set_var("OPENHUMAN_ACTION_DIR", &custom_actions); + } + + let out = super::handle_get_agent_paths(serde_json::Map::new()) + .await + .expect("get_agent_paths handler must succeed"); + let inner = out.get("result").unwrap_or(&out); + let action_dir = inner + .get("action_dir") + .and_then(|v| v.as_str()) + .expect("action_dir field must be a string") + .to_string(); + + assert_eq!( + action_dir, + custom_actions.display().to_string(), + "OPENHUMAN_ACTION_DIR override must propagate through get_agent_paths so the UI displays the actual sandbox path" + ); + + unsafe { + std::env::remove_var("OPENHUMAN_ACTION_DIR"); + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } +} diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 6ecef3d10..195e92ed9 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2784,6 +2784,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_agent_server_status", "openhuman.config_get", "openhuman.config_get_activity_level_settings", + "openhuman.config_get_agent_paths", "openhuman.config_get_agent_settings", "openhuman.config_get_analytics_settings", "openhuman.config_get_autonomy_settings",