feat(settings): AgentAccessPanel reads live action_dir/workspace_dir via RPC (#3245)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
CodeGhost21
2026-06-02 19:21:46 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent ed3c6d7534
commit 3b857d56da
8 changed files with 288 additions and 6 deletions
@@ -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<string | null>(null);
const [savedNote, setSavedNote] = useState<string | null>(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<AgentPaths | null>(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')}
</span>
</div>
<p className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400 font-mono">
~/OpenHuman/projects
<p
className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400 font-mono"
data-testid="agent-access-action-dir">
{agentPaths?.action_dir ?? '~/OpenHuman/projects'}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-500">
{t('settings.agentAccess.actionSandboxDesc')}
@@ -352,8 +368,10 @@ const AgentAccessPanel = () => {
{t('settings.agentAccess.agentBlocked')}
</span>
</div>
<p className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400 font-mono">
~/.openhuman/workspace
<p
className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400 font-mono"
data-testid="agent-access-workspace-dir">
{agentPaths?.workspace_dir ?? '~/.openhuman/workspace'}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-500">
{t('settings.agentAccess.internalStateDesc')}
@@ -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> = {}): AgentSettings =>
...overrides,
});
const agentPaths = (overrides: Partial<AgentPaths> = {}): 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(<AgentAccessPanel />);
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(<AgentAccessPanel />);
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(<AgentAccessPanel />);
expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent(
'~/OpenHuman/projects'
);
expect(screen.getByTestId('agent-access-workspace-dir')).toHaveTextContent(
'~/.openhuman/workspace'
);
});
});
+1
View File
@@ -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',
+27
View File
@@ -345,6 +345,33 @@ export async function openhumanGetAutonomySettings(): Promise<CommandResponse<Au
});
}
/**
* Agent filesystem roots returned by `config_get_agent_paths`. All three are
* already-canonicalised path strings; the UI renders them verbatim instead of
* hard-coding defaults like `~/OpenHuman/projects`.
*
* - `action_dir` — agent CWD for `shell` / `node_exec` / `npm_exec` / file
* writes. Defaults to `projects_dir`; overridable via `OPENHUMAN_ACTION_DIR`.
* - `workspace_dir` — internal product state (memory / sessions / vault).
* Agent-blocked.
* - `projects_dir` — default projects home; matches `action_dir` when no
* override is set.
*/
export interface AgentPaths {
action_dir: string;
workspace_dir: string;
projects_dir: string;
}
export async function openhumanGetAgentPaths(): Promise<CommandResponse<AgentPaths>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AgentPaths>>({
method: CORE_RPC_METHODS.configGetAgentPaths,
});
}
export async function openhumanUpdateAutonomySettings(
update: AutonomySettingsUpdate
): Promise<CommandResponse<ConfigSnapshot>> {
+38
View File
@@ -1938,6 +1938,44 @@ pub async fn get_data_paths() -> Result<RpcOutcome<serde_json::Value>, 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/<id>/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<RpcOutcome<serde_json::Value>, 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;
+32
View File
@@ -257,6 +257,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_get_agent_paths(_params: Map<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) })
}
+101
View File
@@ -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");
}
}
@@ -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",