fix(macos): restart sidecar so TCC permission state updates after System Settings (#133) (#182)

* fix(macos): restart sidecar so permission grants show after System Settings

Fixes #133

- Tauri: restart core after kill+wait; fail fast when port held by non-managed process
- ACL: allow core_rpc_url and restart_core_process (IPC was blocked in Tauri 2)
- Dev: default_core_bin falls through to release search when binaries/ empty
- Core: expose permission_check_process_path on accessibility status
- App: Restart & refresh UX, extractError for invoke, Vitest for RPC + slice
- Stage script: optional dev codesign helper for stable TCC identity

Made-with: Cursor

* refactor(onboarding): update button text for clarity and enhance core process restart handling

- Changed button text in ScreenPermissionsStep from 'Refresh Status' to 'Restart & Refresh Permissions' for better user understanding.
- Introduced a restart lock in CoreProcessHandle to serialize overlapping restart requests, ensuring smoother core process management.
- Updated restart_core_process function to acquire the restart lock before initiating a restart, improving reliability during the process.

* fix: improve text clarity in AccessibilityPanel and ScreenIntelligencePanel

- Adjusted text formatting in AccessibilityPanel for better readability.
- Enhanced text clarity in ScreenIntelligencePanel regarding permission refresh instructions.
- Standardized the formatting of permission_check_process_path in test files for consistency.

---------

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-04-01 16:34:38 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent a5788b88e3
commit 0dd4d1f60b
18 changed files with 624 additions and 43 deletions
+11 -4
View File
@@ -2,14 +2,21 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window (desktop only)",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"platforms": [
"linux",
"macOS",
"windows"
],
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"deep-link:default",
"opener:default"
"opener:default",
"allow-core-process"
]
}
}
@@ -0,0 +1,10 @@
[[permission]]
identifier = "allow-core-process"
description = "Core RPC URL and sidecar restart"
[permission.commands]
allow = [
"core_rpc_url",
"restart_core_process",
]
deny = []
+103 -5
View File
@@ -17,6 +17,7 @@ pub enum CoreRunMode {
pub struct CoreProcessHandle {
child: Arc<Mutex<Option<Child>>>,
task: Arc<Mutex<Option<JoinHandle<()>>>>,
restart_lock: Arc<Mutex<()>>,
port: u16,
core_bin: Option<PathBuf>,
run_mode: CoreRunMode,
@@ -27,6 +28,7 @@ impl CoreProcessHandle {
Self {
child: Arc::new(Mutex::new(None)),
task: Arc::new(Mutex::new(None)),
restart_lock: Arc::new(Mutex::new(())),
port,
core_bin,
run_mode,
@@ -37,6 +39,11 @@ impl CoreProcessHandle {
format!("http://127.0.0.1:{}/rpc", self.port)
}
/// Acquire the restart lock to serialize overlapping restart requests.
pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.restart_lock.lock().await
}
async fn is_rpc_port_open(&self) -> bool {
matches!(
timeout(
@@ -177,6 +184,76 @@ impl CoreProcessHandle {
Err("core process did not become ready".to_string())
}
/// Restart the core process to pick up updated macOS permission grants.
///
/// macOS caches permission state per-process; the running sidecar never sees
/// a newly granted permission until it restarts. This method shuts down the
/// current child, waits until the RPC port is free (so `ensure_running` does not
/// fast-return while the old listener is still bound), then spawns a fresh instance.
///
/// If another process is listening on the core port (e.g. manual `openhuman core run`),
/// shutdown does not stop it — we time out and return an error instead of a false success.
///
/// Issue: <https://github.com/tinyhumansai/openhuman/issues/133>
pub async fn restart(&self) -> Result<(), String> {
log::info!("[core] restarting core process for permission refresh");
let had_managed_child = {
let guard = self.child.lock().await;
guard.is_some()
};
log::debug!(
"[core] restart: had_managed_child={} before shutdown",
had_managed_child
);
self.shutdown().await;
log::debug!("[core] restart: shutdown complete, checking port {}", self.port);
// If we never spawned the sidecar (something else was already listening), we cannot free
// the port — fail fast with a clear message instead of polling for 8s.
if !had_managed_child && self.is_rpc_port_open().await {
log::error!(
"[core] restart: no child to stop but port {} is open — another process owns it",
self.port
);
return Err(format!(
"Core RPC port {} is already in use by another process (OpenHuman did not start it). Quit any `openhuman core run` in a terminal or free the port, then relaunch the app. You can also set OPENHUMAN_CORE_PORT to a different port.",
self.port
));
}
// After kill+wait on our child, the port should close; poll briefly in case the OS is slow
// to release the socket.
const POLL_MS: u64 = 50;
const MAX_WAIT_MS: u64 = 10_000;
let mut waited_ms: u64 = 0;
while self.is_rpc_port_open().await {
if waited_ms >= MAX_WAIT_MS {
log::error!(
"[core] restart: port {} still in use after {}ms (had_managed_child={})",
self.port,
MAX_WAIT_MS,
had_managed_child
);
return Err(format!(
"Core RPC port {} did not become free after stopping the sidecar. Quit any other process using this port (e.g. `openhuman core run`) or change OPENHUMAN_CORE_PORT.",
self.port
));
}
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
waited_ms += POLL_MS;
}
log::debug!("[core] restart: port free, calling ensure_running");
let result = self.ensure_running().await;
match &result {
Ok(()) => log::info!("[core] restart: core process ready after restart"),
Err(e) => log::error!("[core] restart: failed to restart core process: {e}"),
}
result
}
/// Stop the core process this handle spawned (child or in-process task). Safe to call if
/// nothing was spawned or core was already external.
pub async fn shutdown(&self) {
@@ -186,6 +263,26 @@ impl CoreProcessHandle {
if let Err(e) = child.kill().await {
log::warn!("[core] failed to kill child core process: {e}");
}
// Wait for the process to exit so the RPC listen socket is released before restart
// checks the port (otherwise we can spuriously hit "port still in use").
match timeout(
Duration::from_secs(12),
child.wait(),
)
.await
{
Ok(Ok(status)) => {
log::debug!("[core] child core process reaped after kill: {status}");
}
Ok(Err(e)) => {
log::warn!("[core] wait on child core process after kill: {e}");
}
Err(_) => {
log::warn!(
"[core] timed out waiting for child core process to exit after kill (12s)"
);
}
}
}
let mut task_guard = self.task.lock().await;
if let Some(task) = task_guard.take() {
@@ -244,9 +341,12 @@ pub fn default_core_bin() -> Option<PathBuf> {
}
}
// Dev ergonomics: allow an explicit staged sidecar from src-tauri/binaries in
// debug builds before falling back to self-subcommand spawning.
if cfg!(debug_assertions) {
// Dev: prefer a staged sidecar under src-tauri/binaries, then use the same search as
// release (next to the .app, Resources/, etc.). Previously we returned None here when the
// folder was empty, which forced `core run` on the GUI binary — a different TCC identity than
// `openhuman-core-*` and misleading "still denied" after granting the sidecar name.
#[cfg(debug_assertions)]
{
let binaries_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
if let Ok(entries) = std::fs::read_dir(&binaries_dir) {
for entry in entries.flatten() {
@@ -267,8 +367,6 @@ pub fn default_core_bin() -> Option<PathBuf> {
}
}
}
return None;
}
let exe = std::env::current_exe().ok()?;
+11
View File
@@ -96,6 +96,16 @@ async fn service_uninstall_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "uninstall".into()]).await
}
#[tauri::command]
async fn restart_core_process(
state: tauri::State<'_, core_process::CoreProcessHandle>,
) -> Result<(), String> {
log::info!("[core] restart_core_process: command invoked from frontend");
let _guard = state.inner().restart_lock().await;
log::debug!("[core] restart_core_process: acquired restart lock");
state.inner().restart().await
}
fn is_daemon_mode() -> bool {
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
}
@@ -220,6 +230,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
core_rpc_url,
restart_core_process,
service_install_direct,
service_start_direct,
service_stop_direct,
@@ -4,6 +4,7 @@ import {
fetchAccessibilityStatus,
fetchAccessibilityVisionRecent,
flushAccessibilityVision,
refreshPermissionsWithRestart,
requestAccessibilityPermission,
startAccessibilitySession,
stopAccessibilitySession,
@@ -50,6 +51,7 @@ const AccessibilityPanel = () => {
status,
isLoading,
isRequestingPermissions,
isRestartingCore,
isStartingSession,
isStoppingSession,
isLoadingVision,
@@ -82,6 +84,11 @@ const AccessibilityPanel = () => {
void dispatch(fetchAccessibilityVisionRecent(10));
}, [dispatch]);
const anyPermissionDenied =
status?.permissions.screen_recording === 'denied' ||
status?.permissions.accessibility === 'denied' ||
status?.permissions.input_monitoring === 'denied';
const screenMonitoring =
featureOverrides.screen_monitoring ?? status?.features.screen_monitoring ?? true;
const deviceControl = featureOverrides.device_control ?? status?.features.device_control ?? true;
@@ -125,34 +132,67 @@ const AccessibilityPanel = () => {
value={status?.permissions.input_monitoring ?? 'unknown'}
/>
{anyPermissionDenied && (
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200 space-y-1">
<p>
After granting in System Settings, click &ldquo;Restart &amp; Refresh&rdquo; below.
</p>
{status?.permission_check_process_path ? (
<p className="opacity-75 text-xs">
Enable the same app macOS lists for this path (TCC is per executable).{' '}
<span className="font-mono break-all text-stone-300">
{status.permission_check_process_path}
</span>
</p>
) : null}
<p className="opacity-75">
Still stuck? Remove the old entry for this app in System Settings Privacy, then
click &ldquo;Request&rdquo; again. For dev, run{' '}
<span className="font-mono text-xs">yarn core:stage</span> so the sidecar matches
the staged binary name.
</p>
</div>
)}
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('screen_recording'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Screen Recording'}
</button>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
</button>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('input_monitoring'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
</button>
<button
type="button"
onClick={() => void dispatch(fetchAccessibilityStatus())}
disabled={isLoading}
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
{anyPermissionDenied ? (
<button
type="button"
onClick={() => void dispatch(refreshPermissionsWithRestart())}
disabled={isRestartingCore || isLoading}
className="rounded-lg border border-amber-500/60 bg-amber-500/20 px-3 py-2 text-sm text-amber-200 disabled:opacity-50">
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
</button>
) : (
<button
type="button"
onClick={() => void dispatch(fetchAccessibilityStatus())}
disabled={isLoading || isRestartingCore}
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
)}
</section>
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
@@ -5,6 +5,7 @@ import {
fetchAccessibilityStatus,
fetchAccessibilityVisionRecent,
flushAccessibilityVision,
refreshPermissionsWithRestart,
requestAccessibilityPermission,
startAccessibilitySession,
stopAccessibilitySession,
@@ -52,6 +53,7 @@ const ScreenIntelligencePanel = () => {
status,
isLoading,
isRequestingPermissions,
isRestartingCore,
isStartingSession,
isStoppingSession,
isLoadingVision,
@@ -117,6 +119,11 @@ const ScreenIntelligencePanel = () => {
[status?.session.remaining_ms]
);
const anyPermissionDenied =
status?.permissions.screen_recording === 'denied' ||
status?.permissions.accessibility === 'denied' ||
status?.permissions.input_monitoring === 'denied';
const startDisabled =
isStartingSession ||
isLoading ||
@@ -172,34 +179,61 @@ const ScreenIntelligencePanel = () => {
value={status?.permissions.input_monitoring ?? 'unknown'}
/>
{anyPermissionDenied && (
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200 space-y-1">
<p>
After granting in System Settings, click &ldquo;Restart &amp; Refresh
Permissions&rdquo; so a new core process picks up the grants.
</p>
{status?.permission_check_process_path ? (
<p className="opacity-75 text-xs">
macOS applies privacy to this executable:{' '}
<span className="font-mono break-all text-stone-300">
{status.permission_check_process_path}
</span>
</p>
) : null}
</div>
)}
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('screen_recording'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Screen Recording'}
</button>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
</button>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('input_monitoring'))}
disabled={isRequestingPermissions}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
</button>
<button
type="button"
onClick={() => void dispatch(fetchAccessibilityStatus())}
disabled={isLoading}
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
{anyPermissionDenied ? (
<button
type="button"
onClick={() => void dispatch(refreshPermissionsWithRestart())}
disabled={isRestartingCore || isLoading}
className="rounded-lg border border-amber-500/60 bg-amber-500/20 px-3 py-2 text-sm text-amber-200 disabled:opacity-50">
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
</button>
) : (
<button
type="button"
onClick={() => void dispatch(fetchAccessibilityStatus())}
disabled={isLoading || isRestartingCore}
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
)}
</section>
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
@@ -70,6 +70,7 @@ const createStore = () =>
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isRestartingCore: false,
isStartingSession: false,
isStoppingSession: false,
isLoadingVision: false,
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import {
fetchAccessibilityStatus,
refreshPermissionsWithRestart,
requestAccessibilityPermission,
} from '../../../store/accessibilitySlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -15,9 +16,8 @@ interface ScreenPermissionsStepProps {
const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { status, isLoading, isRequestingPermissions } = useAppSelector(
state => state.accessibility
);
const { status, isLoading, isRequestingPermissions, isRestartingCore, lastError } =
useAppSelector(state => state.accessibility);
useEffect(() => {
void dispatch(fetchAccessibilityStatus());
@@ -63,20 +63,39 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte
</div>
{!isGranted ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
disabled={isRequestingPermissions || isLoading}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
</button>
<button
type="button"
onClick={() => navigate('/settings/accessibility')}
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
Open Accessibility
</button>
</div>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
disabled={isRequestingPermissions || isLoading}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
</button>
<button
type="button"
onClick={() => navigate('/settings/accessibility')}
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
Open Accessibility
onClick={() => void dispatch(refreshPermissionsWithRestart())}
disabled={isRestartingCore || isLoading}
className="w-full py-2 text-sm font-medium rounded-xl border border-stone-700 hover:border-stone-500 opacity-70 hover:opacity-100 transition-all disabled:opacity-40">
{isRestartingCore ? 'Restarting core...' : 'Restart & Refresh Permissions'}
</button>
{(lastError || status?.permission_check_process_path) && (
<div className="text-xs text-stone-400 text-center px-2 space-y-1">
{lastError ? <p className="text-coral-400">{lastError}</p> : null}
{status?.permission_check_process_path ? (
<p className="font-mono break-all text-stone-500">
Grant access for: {status.permission_check_process_path}
</p>
) : null}
</div>
)}
</div>
) : (
<button
@@ -1,8 +1,56 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory';
import type { AccessibilityStatus, CommandResponse } from '../../utils/tauriCommands';
import { callCoreRpc } from '../coreRpcClient';
function sampleAccessibilityStatus(
overrides: Partial<AccessibilityStatus> = {}
): AccessibilityStatus {
return {
platform_supported: true,
permissions: {
screen_recording: 'denied',
accessibility: 'granted',
input_monitoring: 'unknown',
},
features: { screen_monitoring: true, device_control: true, predictive_input: true },
session: {
active: false,
started_at_ms: null,
expires_at_ms: null,
remaining_ms: null,
ttl_secs: 300,
panic_hotkey: 'Cmd+Shift+.',
stop_reason: null,
frames_in_memory: 0,
last_capture_at_ms: null,
last_context: null,
vision_enabled: true,
vision_state: 'idle',
vision_queue_depth: 0,
last_vision_at_ms: null,
last_vision_summary: null,
},
config: {
enabled: true,
capture_policy: 'hybrid',
policy_mode: 'all_except_blacklist',
baseline_fps: 1,
vision_enabled: true,
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
allowlist: [],
denylist: [],
},
denylist: [],
is_context_blocked: false,
permission_check_process_path: '/tmp/openhuman-core-aarch64-apple-darwin',
...overrides,
};
}
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) }));
vi.mock('../../lib/ai/localCoreAiMemory', () => ({
dispatchLocalAiMethod: vi.fn(async (_method: string) => ({ source: 'local-ai' })),
@@ -43,6 +91,35 @@ describe('coreRpcClient', () => {
expect(body.method).toBe('openhuman.screen_intelligence_status');
});
test('fetches accessibility_status CommandResponse with permissions and process path', async () => {
const fetchMock = vi.mocked(fetch);
const status = sampleAccessibilityStatus({
permission_check_process_path:
'/Users/dev/openhuman/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin',
});
const envelope: CommandResponse<AccessibilityStatus> = {
result: status,
logs: ['screen intelligence status fetched'],
};
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonrpc: '2.0', id: 99, result: envelope }),
} as Response);
const out = await callCoreRpc<CommandResponse<AccessibilityStatus>>({
method: 'openhuman.accessibility_status',
});
expect(out.logs).toContain('screen intelligence status fetched');
expect(out.result.permissions.screen_recording).toBe('denied');
expect(out.result.permissions.accessibility).toBe('granted');
expect(out.result.permissions.input_monitoring).toBe('unknown');
expect(out.result.permission_check_process_path).toBe(
'/Users/dev/openhuman/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
);
});
test('throws clean error when JSON-RPC error payload is returned', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
@@ -4,6 +4,7 @@ import type { AccessibilityStatus, CaptureTestResult } from '../../utils/tauriCo
import reducer, {
clearAccessibilityError,
fetchAccessibilityStatus,
refreshPermissionsWithRestart,
runCaptureTest,
setAccessibilityStatus,
startAccessibilitySession,
@@ -49,6 +50,7 @@ const sampleStatus: AccessibilityStatus = {
},
denylist: ['wallet'],
is_context_blocked: false,
permission_check_process_path: '/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin',
};
describe('accessibilitySlice', () => {
@@ -77,6 +79,27 @@ describe('accessibilitySlice', () => {
expect(fulfilled.status?.permissions.accessibility).toBe('granted');
});
it('stores permission_check_process_path from fetched status', () => {
const fulfilled = reducer(
undefined,
fetchAccessibilityStatus.fulfilled(sampleStatus, 'req-path', undefined)
);
expect(fulfilled.status?.permission_check_process_path).toBe(
'/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
);
});
it('stores permission_check_process_path after refreshPermissionsWithRestart', () => {
const fulfilled = reducer(
undefined,
refreshPermissionsWithRestart.fulfilled(sampleStatus, 'req-restart', undefined)
);
expect(fulfilled.isRestartingCore).toBe(false);
expect(fulfilled.status?.permission_check_process_path).toBe(
'/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
);
});
it('tracks session start/stop async flags', () => {
const starting = reducer(undefined, { type: startAccessibilitySession.pending.type });
expect(starting.isStartingSession).toBe(true);
+75
View File
@@ -16,6 +16,7 @@ import {
openhumanAccessibilityVisionFlush,
openhumanAccessibilityVisionRecent,
openhumanScreenIntelligenceCaptureTest,
restartCoreProcess,
} from '../utils/tauriCommands';
interface AccessibilityState {
@@ -25,6 +26,7 @@ interface AccessibilityState {
isCaptureTestRunning: boolean;
isLoading: boolean;
isRequestingPermissions: boolean;
isRestartingCore: boolean;
isStartingSession: boolean;
isStoppingSession: boolean;
isLoadingVision: boolean;
@@ -39,6 +41,7 @@ const initialState: AccessibilityState = {
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isRestartingCore: false,
isStartingSession: false,
isStoppingSession: false,
isLoadingVision: false,
@@ -50,6 +53,15 @@ const extractError = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === 'string' && error.trim()) {
return error;
}
if (error && typeof error === 'object') {
const msg = (error as { message?: unknown }).message;
if (typeof msg === 'string' && msg.trim()) {
return msg;
}
}
return fallback;
};
@@ -91,6 +103,56 @@ export const requestAccessibilityPermission = createAsyncThunk(
}
);
/**
* Restart the core sidecar and re-fetch accessibility status.
*
* macOS caches permission state per-process. The running sidecar never sees a
* newly granted permission until it exits and a fresh process starts. This thunk:
* 1. Restarts the core sidecar via the `restart_core_process` Tauri command.
* 2. Waits briefly, then re-fetches status with retries while the new sidecar binds.
* 3. Updates Redux so the UI reflects the updated grants.
*
* @see https://github.com/tinyhumansai/openhuman/issues/133 — stale DENIED after granting in System Settings
*/
export const refreshPermissionsWithRestart = createAsyncThunk(
'accessibility/refreshPermissionsWithRestart',
async (_, { rejectWithValue }) => {
try {
console.debug('[accessibility] refreshPermissionsWithRestart: starting core restart');
await restartCoreProcess();
console.debug('[accessibility] refreshPermissionsWithRestart: waiting for sidecar ready');
await new Promise<void>(resolve => setTimeout(resolve, 400));
console.debug('[accessibility] refreshPermissionsWithRestart: fetching updated status');
for (let attempt = 1; attempt <= 5; attempt++) {
try {
const response = await openhumanAccessibilityStatus();
console.debug(
'[accessibility] refreshPermissionsWithRestart: done — screen_recording=%s accessibility=%s input_monitoring=%s',
response.result.permissions.screen_recording,
response.result.permissions.accessibility,
response.result.permissions.input_monitoring
);
return response.result;
} catch (e) {
if (attempt === 5) {
throw e;
}
console.debug(
'[accessibility] refreshPermissionsWithRestart: status fetch failed (attempt %s), retrying…',
attempt
);
await new Promise<void>(resolve => setTimeout(resolve, 350 * attempt));
}
}
throw new Error('Failed to fetch accessibility status after core restart');
} catch (error) {
const msg = extractError(error, 'Failed to restart core and refresh permissions');
console.error('[accessibility] refreshPermissionsWithRestart: error —', msg, error);
return rejectWithValue(msg);
}
}
);
export const startAccessibilitySession = createAsyncThunk(
'accessibility/startSession',
async (
@@ -233,6 +295,19 @@ const accessibilitySlice = createSlice({
state.lastError =
(action.payload as string) ?? 'Failed to request accessibility permission';
})
.addCase(refreshPermissionsWithRestart.pending, state => {
state.isRestartingCore = true;
state.lastError = null;
})
.addCase(refreshPermissionsWithRestart.fulfilled, (state, action) => {
state.isRestartingCore = false;
state.status = action.payload;
})
.addCase(refreshPermissionsWithRestart.rejected, (state, action) => {
state.isRestartingCore = false;
state.lastError =
(action.payload as string) ?? 'Failed to restart core and refresh permissions';
})
.addCase(startAccessibilitySession.pending, state => {
state.isStartingSession = true;
state.lastError = null;
+22
View File
@@ -180,6 +180,26 @@ export async function setWindowTitle(title: string): Promise<void> {
await getCurrentWindow().setTitle(title);
}
/**
* Restart the core sidecar process.
*
* macOS caches permission grants per-process; the running sidecar never sees
* a newly granted permission until it restarts. Call this after the user grants
* permissions in System Settings, then re-fetch accessibility status so the UI
* reflects the updated grants.
*
* @see https://github.com/tinyhumansai/openhuman/issues/133
*/
export async function restartCoreProcess(): Promise<void> {
if (!isTauri()) {
console.debug('[core] restartCoreProcess: skipped — not running in Tauri');
return;
}
console.debug('[core] restartCoreProcess: invoking restart_core_process');
await invoke<void>('restart_core_process');
console.debug('[core] restartCoreProcess: done');
}
// --- Memory Commands ---
/**
@@ -597,6 +617,8 @@ export interface AccessibilityStatus {
config: AccessibilityConfig;
denylist: string[];
is_context_blocked: boolean;
/** Absolute path of the core binary; macOS TCC applies to this executable. */
permission_check_process_path?: string | null;
}
export interface AccessibilityStartSessionParams {
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# One-time setup: create a stable local code-signing certificate for the
# openhuman-core sidecar. Run this once per development machine.
#
# Why: macOS TCC identifies unsigned binaries by content hash (Mach-O UUID).
# Every `yarn core:stage` recompiles the sidecar, changing its hash, so TCC
# no longer matches the old grant. Signing with a stable certificate causes
# TCC to use the certificate identity instead — grants persist across rebuilds.
#
# After running this script:
# 1. yarn core:stage (signs the sidecar with the new cert)
# 2. In OpenHuman → Request Permissions (removes old stale TCC entry,
# registers current binary)
# 3. Grant in System Settings → Refresh Status
# From this point the grant survives future `yarn core:stage` runs.
set -euo pipefail
IDENTITY="OpenHuman Dev Signer"
KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
TMPDIR_CERT=$(mktemp -d)
KEY="$TMPDIR_CERT/openhuman-dev.key"
CERT="$TMPDIR_CERT/openhuman-dev.crt"
P12="$TMPDIR_CERT/openhuman-dev.p12"
P12_PASS="${OPENHUMAN_P12_PASS:-openhuman-dev}"
cleanup() {
rm -rf "$TMPDIR_CERT"
}
trap cleanup EXIT
# ── Check if already set up ──────────────────────────────────────────────────
if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then
echo "[setup-dev-codesign] Certificate \"$IDENTITY\" already exists — nothing to do."
echo "[setup-dev-codesign] Run 'yarn core:stage' to sign the sidecar."
exit 0
fi
echo "[setup-dev-codesign] Creating self-signed code-signing certificate: \"$IDENTITY\""
# ── Generate key + self-signed certificate ───────────────────────────────────
openssl req \
-newkey rsa:2048 \
-nodes \
-keyout "$KEY" \
-x509 \
-days 3650 \
-out "$CERT" \
-subj "/CN=$IDENTITY" \
2>/dev/null
# ── Bundle to PKCS12 ─────────────────────────────────────────────────────────
openssl pkcs12 \
-export \
-out "$P12" \
-inkey "$KEY" \
-in "$CERT" \
-passout "pass:$P12_PASS" \
2>/dev/null
# ── Import into login Keychain ───────────────────────────────────────────────
security import "$P12" \
-k "$KEYCHAIN" \
-P "$P12_PASS" \
-T /usr/bin/codesign \
-T /usr/bin/security
# ── Trust for code signing ───────────────────────────────────────────────────
security add-trusted-cert \
-d \
-r trustRoot \
-p codeSign \
-k "$KEYCHAIN" \
"$CERT"
echo ""
echo "[setup-dev-codesign] Done. Certificate \"$IDENTITY\" added to login Keychain."
echo ""
echo "Next steps:"
echo " 1. yarn core:stage — rebuilds and signs the sidecar"
echo " 2. In OpenHuman click 'Request Permissions' to register the signed binary"
echo " 3. Grant in System Settings → Privacy & Security → Accessibility"
echo " 4. Click 'Refresh Status'"
echo ""
echo "After this, accessibility grants will survive future 'yarn core:stage' runs."
+22
View File
@@ -62,3 +62,25 @@ if (!isWindows) {
}
console.log(`[core:stage] Staged sidecar: ${dest}`);
// macOS: sign with a stable local dev certificate so macOS TCC uses certificate
// identity (stable across rebuilds) instead of binary content hash (changes
// every compile). Without this, each recompile breaks existing TCC grants.
if (process.platform === "darwin") {
const DEV_IDENTITY = "OpenHuman Dev Signer";
const check = spawnSync(
"bash",
["-c", `security find-identity -v -p codesigning 2>/dev/null | grep "${DEV_IDENTITY}" || true`],
{ cwd: root, encoding: "utf8" },
);
if (check.stdout && check.stdout.includes(DEV_IDENTITY)) {
run("codesign", ["--force", "--sign", DEV_IDENTITY, "--timestamp=none", dest]);
console.log(`[core:stage] Signed sidecar with "${DEV_IDENTITY}"`);
} else {
console.warn(
`[core:stage] Dev signing identity "${DEV_IDENTITY}" not found.\n` +
`[core:stage] Run 'bash scripts/setup-dev-codesign.sh' once to enable stable TCC grants.\n` +
`[core:stage] Without signing, macOS accessibility grants break on every recompile.`,
);
}
}
@@ -251,6 +251,9 @@ impl AccessibilityEngine {
config,
denylist,
is_context_blocked: blocked,
permission_check_process_path: std::env::current_exe()
.ok()
.map(|p| p.display().to_string()),
}
}
+29
View File
@@ -1,4 +1,7 @@
//! JSON-RPC / CLI controller surface for screen capture and accessibility automation.
//!
//! macOS permission UX (stale DENIED until sidecar restarts) is tracked in
//! <https://github.com/tinyhumansai/openhuman/issues/133>.
use serde_json::json;
@@ -178,6 +181,32 @@ pub async fn accessibility_vision_flush() -> Result<RpcOutcome<VisionFlushResult
))
}
/// Re-detect current permission state. Intended to be called after the sidecar
/// restarts so the new process reads freshly granted macOS permissions.
///
/// macOS caches permission grants per-process; the running sidecar never sees an
/// updated grant until it restarts. After `restart_core_process` brings up a fresh
/// sidecar, calling this endpoint returns the authoritative permission state as seen
/// by that new process.
pub async fn accessibility_refresh_permissions() -> Result<RpcOutcome<PermissionStatus>, String> {
log::info!("[screen_intelligence] refresh_permissions:re-detecting permissions");
// `status()` unconditionally calls `detect_permissions()` before returning, so
// fetching the full status and extracting the permissions field is the correct
// way to get a freshly computed permission state.
let full_status = screen_intelligence::global_engine().status().await;
let permissions = full_status.permissions;
log::debug!(
"[screen_intelligence] accessibility_refresh_permissions: screen_recording={:?} accessibility={:?} input_monitoring={:?}",
permissions.screen_recording,
permissions.accessibility,
permissions.input_monitoring,
);
Ok(RpcOutcome::single_log(
permissions,
"accessibility permissions refreshed",
))
}
pub async fn accessibility_capture_test() -> Result<RpcOutcome<CaptureTestResult>, String> {
let result: CaptureTestResult = screen_intelligence::global_engine().capture_test().await;
Ok(RpcOutcome::single_log(
@@ -19,6 +19,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("status"),
schemas("request_permissions"),
schemas("request_permission"),
schemas("refresh_permissions"),
schemas("start_session"),
schemas("stop_session"),
schemas("capture_now"),
@@ -44,6 +45,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("request_permission"),
handler: handle_request_permission,
},
RegisteredController {
schema: schemas("refresh_permissions"),
handler: handle_refresh_permissions,
},
RegisteredController {
schema: schemas("start_session"),
handler: handle_start_session,
@@ -107,6 +112,14 @@ pub fn schemas(function: &str) -> ControllerSchema {
}],
outputs: vec![json_output("permissions", "Permission status payload.")],
},
"refresh_permissions" => ControllerSchema {
namespace: "screen_intelligence",
function: "refresh_permissions",
description: "Re-detect current macOS permission state without requesting new grants. \
Call this after the sidecar restarts to read freshly granted permissions.",
inputs: vec![],
outputs: vec![json_output("permissions", "Freshly detected permission status.")],
},
"start_session" => ControllerSchema {
namespace: "screen_intelligence",
function: "start_session",
@@ -223,6 +236,14 @@ fn handle_request_permissions(_params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_refresh_permissions(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
to_json(
crate::openhuman::screen_intelligence::rpc::accessibility_refresh_permissions().await?,
)
})
}
fn handle_request_permission(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<PermissionRequestParams>(params)?;
@@ -66,6 +66,10 @@ pub struct AccessibilityStatus {
pub config: ScreenIntelligenceConfig,
pub denylist: Vec<String>,
pub is_context_blocked: bool,
/// Absolute path of this core process. macOS privacy (TCC) is per executable; the UI should
/// show this so users enable the same binary in System Settings (see GH #133).
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_check_process_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]