mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Steven Enamakel
parent
ed3c6d7534
commit
3b857d56da
@@ -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,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',
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?) })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user