fix(webview/meet): gate orchestrator handoff on user opt-in (#1299) (#1310)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-05-06 17:43:21 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a08ec95338
commit ba88d8cfc3
25 changed files with 715 additions and 10 deletions
@@ -35,8 +35,9 @@ const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
const PrivacyPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot, setAnalyticsEnabled } = useCoreState();
const { snapshot, setAnalyticsEnabled, setMeetAutoOrchestratorHandoff } = useCoreState();
const analyticsEnabled = snapshot.analyticsEnabled;
const meetAutoHandoff = snapshot.meetAutoOrchestratorHandoff;
const [capabilities, setCapabilities] = useState<AnnotatedCapability[]>([]);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
@@ -73,6 +74,15 @@ const PrivacyPanel = () => {
}
};
const handleToggleMeetAutoHandoff = async () => {
const newValue = !meetAutoHandoff;
try {
await setMeetAutoOrchestratorHandoff(newValue);
} catch (error) {
console.warn('[privacy] failed to persist meet auto-handoff setting:', error);
}
};
return (
<div data-testid="settings-privacy-panel">
<SettingsHeader
@@ -168,6 +178,42 @@ const PrivacyPanel = () => {
</div>
</div>
{/* Meeting Follow-ups Section (#1299) */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
Meeting follow-ups
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">
Auto-handoff Google Meet transcripts to the orchestrator
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
When a Google Meet call ends, OpenHuman&rsquo;s orchestrator can read the
transcript and may take actions like drafting messages, scheduling follow-ups,
or posting summaries to your connected Slack workspace. Off by default.
</p>
</div>
<button
data-testid="privacy-meet-handoff-toggle"
onClick={handleToggleMeetAutoHandoff}
aria-label="Auto-handoff Google Meet transcripts to the orchestrator"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600'
}`}
role="switch"
aria-checked={meetAutoHandoff}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
meetAutoHandoff ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
</div>
{/* Info Box */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="flex items-start space-x-3">
@@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
@@ -7,8 +7,13 @@ import PrivacyPanel from '../PrivacyPanel';
vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({ listCapabilities: vi.fn() }));
const setMeetAutoOrchestratorHandoffMock = vi.fn();
vi.mock('../../../../providers/CoreStateProvider', () => ({
useCoreState: () => ({ snapshot: { analyticsEnabled: false }, setAnalyticsEnabled: vi.fn() }),
useCoreState: () => ({
snapshot: { analyticsEnabled: false, meetAutoOrchestratorHandoff: false },
setAnalyticsEnabled: vi.fn(),
setMeetAutoOrchestratorHandoff: (v: boolean) => setMeetAutoOrchestratorHandoffMock(v),
}),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
@@ -88,7 +93,21 @@ describe('PrivacyPanel', () => {
expect(screen.getByTestId('privacy-load-error')).toBeTruthy();
});
expect(screen.queryByTestId('privacy-capability-list')).toBeNull();
// Analytics toggle still rendered
expect(screen.getByRole('switch')).toBeTruthy();
// Analytics + meet-handoff toggles still rendered
expect(screen.getAllByRole('switch').length).toBeGreaterThanOrEqual(2);
});
it('flips the meet auto-handoff toggle from OFF to ON when clicked (#1299)', async () => {
vi.mocked(listCapabilities).mockResolvedValue([]);
renderWithProviders(<PrivacyPanel />);
const toggle = await screen.findByTestId('privacy-meet-handoff-toggle');
expect(toggle.getAttribute('aria-checked')).toBe('false');
fireEvent.click(toggle);
await waitFor(() => {
expect(setMeetAutoOrchestratorHandoffMock).toHaveBeenCalledWith(true);
});
});
});
@@ -10,6 +10,7 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
onboardingCompleted: true,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
...overrides,
+11
View File
@@ -46,6 +46,16 @@ export interface CoreAppSnapshot {
*/
chatOnboardingCompleted: boolean;
analyticsEnabled: boolean;
/**
* Whether ending a Google Meet call hands the transcript to the
* orchestrator agent for proactive follow-up actions (drafting Slack
* messages, scheduling, etc.). Mirrors
* `Config::meet.auto_orchestrator_handoff` in the Rust core (see
* `src/openhuman/config/schema/meet.rs`). Defaults to `false` —
* privacy-conservative gate added in #1299. The webview meet flow
* reads this before invoking `handoffToOrchestrator`.
*/
meetAutoOrchestratorHandoff: boolean;
localState: CoreLocalState;
runtime: CoreRuntimeSnapshot;
}
@@ -66,6 +76,7 @@ const emptySnapshot: CoreAppSnapshot = {
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
};
+21
View File
@@ -32,6 +32,7 @@ import { loadThreads, resetThreadCachesPreservingSelection } from '../store/thre
import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage';
import {
openhumanUpdateAnalyticsSettings,
openhumanUpdateMeetSettings,
restartApp,
setOnboardingCompleted,
storeSession,
@@ -66,6 +67,7 @@ interface CoreStateContextValue extends CoreState {
refreshTeamMembers: (teamId: string) => Promise<void>;
refreshTeamInvites: (teamId: string) => Promise<void>;
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise<void>;
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
setEncryptionKey: (value: string | null) => Promise<void>;
setPrimaryWalletAddress: (value: string | null) => Promise<void>;
@@ -142,6 +144,7 @@ function normalizeSnapshot(
onboardingCompleted: result.onboardingCompleted,
chatOnboardingCompleted: result.chatOnboardingCompleted,
analyticsEnabled: result.analyticsEnabled,
meetAutoOrchestratorHandoff: result.meetAutoOrchestratorHandoff ?? false,
localState: {
encryptionKey: result.localState.encryptionKey ?? null,
primaryWalletAddress: result.localState.primaryWalletAddress ?? null,
@@ -479,6 +482,22 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
[commitState, refresh]
);
const setMeetAutoOrchestratorHandoff = useCallback(
async (enabled: boolean) => {
await openhumanUpdateMeetSettings({ auto_orchestrator_handoff: enabled });
// Optimistic commit so the toggle flips instantly; full snapshot
// refresh follows so the cached value matches what core just wrote.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, meetAutoOrchestratorHandoff: enabled },
}));
await refresh().catch(err => {
log('refresh failed after setMeetAutoOrchestratorHandoff: %O', sanitizeError(err));
});
},
[commitState, refresh]
);
const setOnboardingCompletedFlag = useCallback(
async (value: boolean) => {
await setOnboardingCompleted(value);
@@ -567,6 +586,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
refreshTeamInvites,
patchSnapshot,
setAnalyticsEnabled,
setMeetAutoOrchestratorHandoff,
setOnboardingCompletedFlag,
setEncryptionKey: value => updateLocalState({ encryptionKey: value }),
setPrimaryWalletAddress: value => updateLocalState({ primaryWalletAddress: value }),
@@ -582,6 +602,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
refreshTeams,
patchSnapshot,
setAnalyticsEnabled,
setMeetAutoOrchestratorHandoff,
setOnboardingCompletedFlag,
state,
storeSessionToken,
@@ -74,6 +74,7 @@ function resetCoreStateStore() {
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as coreStateApi from '../../services/coreStateApi';
import * as tauriCommands from '../../utils/tauriCommands';
import { setCoreStateSnapshot } from '../../lib/coreState/store';
import CoreStateProvider, { useCoreState } from '../CoreStateProvider';
@@ -78,6 +79,7 @@ function resetCoreStateStore() {
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
@@ -241,4 +243,68 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' })
);
});
it('setMeetAutoOrchestratorHandoff(true) calls update RPC + flips snapshot optimistically (#1299)', async () => {
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
} as never);
let ctx: CoreStateContextValue | undefined;
render(
<CoreStateProvider>
<Consumer
captureCtx={next => {
ctx = next;
}}
/>
</CoreStateProvider>
);
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
expect(ctx?.snapshot.meetAutoOrchestratorHandoff).toBe(false);
await act(async () => {
await ctx!.setMeetAutoOrchestratorHandoff(true);
});
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
auto_orchestrator_handoff: true,
});
});
it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => {
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
} as never);
let ctx: CoreStateContextValue | undefined;
render(
<CoreStateProvider>
<Consumer
captureCtx={next => {
ctx = next;
}}
/>
</CoreStateProvider>
);
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
fetchSnapshot.mockRejectedValueOnce(new Error('refresh failed'));
await act(async () => {
await expect(ctx!.setMeetAutoOrchestratorHandoff(false)).resolves.toBeUndefined();
});
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
auto_orchestrator_handoff: false,
});
});
});
@@ -0,0 +1,126 @@
/**
* Privacy gate regression tests for issue #1299.
*
* Verifies that `maybeHandoffToOrchestrator` only invokes the orchestrator
* (creating a fresh chat thread + sending the transcript prompt) when the
* user has explicitly opted in via the `meet.auto_orchestrator_handoff`
* setting. Default-OFF must skip both `threadApi.createNewThread` and
* `chatSend` entirely. RPC failures fail closed (no handoff).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __testInternals } from '../webviewAccountService';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(undefined),
isTauri: vi.fn().mockReturnValue(true),
}));
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(() => {}) }));
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() }));
const createNewThreadMock = vi.fn();
vi.mock('../api/threadApi', () => ({
threadApi: { createNewThread: (...args: unknown[]) => createNewThreadMock(...args) },
}));
const chatSendMock = vi.fn();
vi.mock('../chatService', () => ({ chatSend: (...args: unknown[]) => chatSendMock(...args) }));
const getMeetSettingsMock = vi.fn();
vi.mock('../../utils/tauriCommands/config', () => ({
openhumanGetMeetSettings: () => getMeetSettingsMock(),
}));
interface MockMeetingSession {
code: string;
startedAt: number;
snapshots: never[];
}
function makeSession(): MockMeetingSession {
return { code: 'abc-defg-hij', startedAt: Date.now() - 60_000, snapshots: [] };
}
describe('maybeHandoffToOrchestrator (#1299 privacy gate)', () => {
beforeEach(() => {
createNewThreadMock.mockReset();
chatSendMock.mockReset();
getMeetSettingsMock.mockReset();
createNewThreadMock.mockResolvedValue({ id: 'thread-1' });
chatSendMock.mockResolvedValue(undefined);
});
it('skips handoff when auto_orchestrator_handoff is false', async () => {
getMeetSettingsMock.mockResolvedValue({
result: { auto_orchestrator_handoff: false },
logs: [],
});
await __testInternals.maybeHandoffToOrchestrator(
'acct-test',
// The function only reads `code` and `startedAt`. Ts cast is enough
// for a structural mock — full MeetingSession is heavier than needed.
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
Date.now(),
'## Transcript\n[10:00:00] Alice: hello',
new Set(['Alice'])
);
expect(createNewThreadMock).not.toHaveBeenCalled();
expect(chatSendMock).not.toHaveBeenCalled();
});
it('skips handoff when settings field is missing', async () => {
getMeetSettingsMock.mockResolvedValue({ result: {}, logs: [] });
await __testInternals.maybeHandoffToOrchestrator(
'acct-test',
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
Date.now(),
'## Transcript',
new Set()
);
expect(createNewThreadMock).not.toHaveBeenCalled();
expect(chatSendMock).not.toHaveBeenCalled();
});
it('fails closed (no handoff) when settings RPC throws', async () => {
getMeetSettingsMock.mockRejectedValue(new Error('core rpc down'));
await __testInternals.maybeHandoffToOrchestrator(
'acct-test',
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
Date.now(),
'## Transcript',
new Set()
);
expect(createNewThreadMock).not.toHaveBeenCalled();
expect(chatSendMock).not.toHaveBeenCalled();
});
it('fires handoff when auto_orchestrator_handoff is true', async () => {
getMeetSettingsMock.mockResolvedValue({
result: { auto_orchestrator_handoff: true },
logs: [],
});
await __testInternals.maybeHandoffToOrchestrator(
'acct-test',
makeSession() as unknown as Parameters<typeof __testInternals.maybeHandoffToOrchestrator>[1],
Date.now(),
'## Transcript\n[10:00:00] Alice: hello',
new Set(['Alice'])
);
expect(createNewThreadMock).toHaveBeenCalledTimes(1);
expect(chatSendMock).toHaveBeenCalledTimes(1);
const sendArgs = chatSendMock.mock.calls[0][0];
expect(sendArgs.threadId).toBe('thread-1');
expect(sendArgs.message).toContain('## Transcript');
});
});
+14 -1
View File
@@ -33,6 +33,13 @@ interface AppStateSnapshotResult {
onboardingCompleted: boolean;
chatOnboardingCompleted: boolean;
analyticsEnabled: boolean;
/**
* Mirror of `Config::meet.auto_orchestrator_handoff` (#1299). Older
* core builds may omit the field on the wire — `fetchCoreAppSnapshot`
* normalises the missing case to `false` before returning so callers
* never observe `undefined` here.
*/
meetAutoOrchestratorHandoff?: boolean;
localState: {
encryptionKey?: string | null;
primaryWalletAddress?: string | null;
@@ -50,7 +57,13 @@ export const fetchCoreAppSnapshot = async (): Promise<AppStateSnapshotResult> =>
const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({
method: 'openhuman.app_state_snapshot',
});
return response.result;
// Normalise the optional #1299 field at the API boundary so older core
// builds without `meetAutoOrchestratorHandoff` still surface the
// privacy-conservative `false` to callers (e.g. CoreStateProvider).
return {
...response.result,
meetAutoOrchestratorHandoff: response.result.meetAutoOrchestratorHandoff ?? false,
};
};
export const updateCoreLocalState = async (params: UpdateCoreLocalStateParams): Promise<void> => {
+48 -1
View File
@@ -12,6 +12,7 @@ import {
import { addIntegrationNotification } from '../store/notificationSlice';
import { fetchRespondQueue } from '../store/providerSurfaceSlice';
import type { AccountProvider, IngestedMessage } from '../types/accounts';
import { openhumanGetMeetSettings } from '../utils/tauriCommands/config';
import { threadApi } from './api/threadApi';
import { chatSend } from './chatService';
import { callCoreRpc } from './coreRpcClient';
@@ -647,7 +648,7 @@ async function flushMeetingSession(
);
if (segments.length > 0) {
await handoffToOrchestrator(accountId, session, endedAt, markdown, participants);
await maybeHandoffToOrchestrator(accountId, session, endedAt, markdown, participants);
}
} catch (err) {
errLog('meet: memory write failed: %o', err);
@@ -664,6 +665,45 @@ async function flushMeetingSession(
}
}
/**
* Privacy gate (#1299) — only call `handoffToOrchestrator` when the
* user has explicitly opted in via the `meet.auto_orchestrator_handoff`
* setting. Without this gate, every Meet call ended would auto-feed the
* transcript to the orchestrator, which has the full Slack tool surface
* and would proactively post summaries to public channels (e.g.
* `#general`) without consent.
*
* The setting is read fresh per call rather than cached so toggle
* changes mid-session take effect immediately. Failure to read settings
* (e.g. core RPC down) is treated as opt-out — privacy-conservative.
*/
async function maybeHandoffToOrchestrator(
accountId: string,
session: MeetingSession,
endedAt: number,
transcriptMarkdown: string,
participants: Set<string>
): Promise<void> {
let optedIn = false;
try {
const settings = await openhumanGetMeetSettings();
optedIn = settings?.result?.auto_orchestrator_handoff === true;
} catch (err) {
// Fail-closed: if we can't read the setting, do not hand off.
errLog('meet: failed to read meet settings, defaulting to OFF: %o', err);
}
if (!optedIn) {
log(
'meet: orchestrator handoff disabled (auto_orchestrator_handoff !== true), skipping for code=%s',
session.code
);
return;
}
await handoffToOrchestrator(accountId, session, endedAt, transcriptMarkdown, participants);
}
/**
* After a meeting transcript is persisted, open a fresh thread with the
* orchestrator agent and hand it the transcript so it can extract notes
@@ -672,6 +712,10 @@ async function flushMeetingSession(
* The orchestrator IS the LLM here — there's no separate summarisation
* call. It produces structured notes inline as part of its reply and
* routes any actionable items to its subagents/skills.
*
* IMPORTANT: This function is the privacy-sensitive path. Callers must
* gate it on user opt-in via {@link maybeHandoffToOrchestrator} — do
* NOT invoke it directly from session-end code paths. See #1299.
*/
async function handoffToOrchestrator(
accountId: string,
@@ -1058,3 +1102,6 @@ async function flushMeetingIfAny(accountId: string, reason: string): Promise<voi
activeMeetings.delete(accountId);
await flushMeetingSession(accountId, session, Date.now(), reason);
}
/** Test-only re-exports — do NOT import outside `__tests__/`. */
export const __testInternals = { maybeHandoffToOrchestrator };
@@ -21,6 +21,7 @@ function makeCoreState(token: string | null): CoreState {
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
+9
View File
@@ -111,6 +111,15 @@ vi.mock('../utils/tauriCommands', () => ({
.fn()
.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }),
openhumanAgentServerStatus: vi.fn().mockResolvedValue({ result: { running: true }, logs: [] }),
openhumanUpdateMeetSettings: vi
.fn()
.mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
}),
openhumanGetMeetSettings: vi
.fn()
.mockResolvedValue({ result: { auto_orchestrator_handoff: false }, logs: [] }),
exchangeToken: vi.fn(),
invoke: vi.fn(),
}));
@@ -10,12 +10,16 @@ describe('tauriCommands/config', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let openhumanUpdateLocalAiSettings: typeof import('./config').openhumanUpdateLocalAiSettings;
let openhumanUpdateMeetSettings: typeof import('./config').openhumanUpdateMeetSettings;
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual<typeof import('./config')>('./config');
openhumanUpdateLocalAiSettings = actual.openhumanUpdateLocalAiSettings;
openhumanUpdateMeetSettings = actual.openhumanUpdateMeetSettings;
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
});
afterEach(() => {
@@ -44,4 +48,43 @@ describe('tauriCommands/config', () => {
});
});
});
describe('openhumanUpdateMeetSettings (#1299)', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(
openhumanUpdateMeetSettings({ auto_orchestrator_handoff: true })
).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('forwards the patch to openhuman.config_update_meet_settings', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
});
await openhumanUpdateMeetSettings({ auto_orchestrator_handoff: true });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.config_update_meet_settings',
params: { auto_orchestrator_handoff: true },
});
});
});
describe('openhumanGetMeetSettings (#1299)', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanGetMeetSettings()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('reads via openhuman.config_get_meet_settings', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { auto_orchestrator_handoff: true }, logs: [] });
const out = await openhumanGetMeetSettings();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.config_get_meet_settings',
});
expect(out.result.auto_orchestrator_handoff).toBe(true);
});
});
});
+23
View File
@@ -204,6 +204,29 @@ export async function openhumanGetAnalyticsSettings(): Promise<
});
}
export async function openhumanUpdateMeetSettings(update: {
auto_orchestrator_handoff?: boolean;
}): Promise<CommandResponse<ConfigSnapshot>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
method: 'openhuman.config_update_meet_settings',
params: update,
});
}
export async function openhumanGetMeetSettings(): Promise<
CommandResponse<{ auto_orchestrator_handoff: boolean }>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<{ auto_orchestrator_handoff: boolean }>>({
method: 'openhuman.config_get_meet_settings',
});
}
export async function openhumanGetRuntimeFlags(): Promise<CommandResponse<RuntimeFlags>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
+8 -1
View File
@@ -83,6 +83,11 @@ pub struct AppStateSnapshot {
/// `complete_onboarding(action="complete")`.
pub chat_onboarding_completed: bool,
pub analytics_enabled: bool,
/// Mirror of `Config::meet.auto_orchestrator_handoff` — gates whether
/// ending a Google Meet call hands the transcript to the orchestrator
/// agent for proactive follow-up actions. Default `false`. See
/// issue #1299.
pub meet_auto_orchestrator_handoff: bool,
pub local_state: StoredAppState,
pub runtime: RuntimeSnapshot,
}
@@ -464,11 +469,12 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
let runtime = build_runtime_snapshot(&config).await;
debug!(
"{LOG_PREFIX} snapshot auth={} onboarding={} chat_onboarding={} analytics={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
"{LOG_PREFIX} snapshot auth={} onboarding={} chat_onboarding={} analytics={} meet_handoff={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
auth.is_authenticated,
config.onboarding_completed,
config.chat_onboarding_completed,
config.observability.analytics_enabled,
config.meet.auto_orchestrator_handoff,
local_state.primary_wallet_address.is_some(),
runtime.screen_intelligence.session.active,
runtime.local_ai.state,
@@ -484,6 +490,7 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
onboarding_completed: config.onboarding_completed,
chat_onboarding_completed: config.chat_onboarding_completed,
analytics_enabled: config.observability.analytics_enabled,
meet_auto_orchestrator_handoff: config.meet.auto_orchestrator_handoff,
local_state,
runtime,
},
+1 -1
View File
@@ -13,7 +13,7 @@ Authoritative TOML-backed configuration layer. Owns the `Config` schema (every d
- Workspace identity helpers: `pub fn clear_active_user`, `default_root_openhuman_dir`, `pre_login_user_dir`, `read_active_user_id`, `user_openhuman_dir`, `write_active_user_id`, `PRE_LOGIN_USER_ID``schema/identity_cost.rs`.
- `pub mod ops` (re-exported as `rpc`) — `ops.rs` — RPC handlers and settings mutation.
- `pub mod settings_cli``settings_cli.rs``openhuman settings ...` CLI surface.
- RPC `config.{get_config, update_model_settings, update_memory_settings, update_screen_intelligence_settings, update_runtime_settings, update_browser_settings, resolve_api_url, get_runtime_flags, set_browser_allow_all, workspace_onboarding_flag_exists, workspace_onboarding_flag_set, update_analytics_settings, get_analytics_settings, agent_server_status, reset_local_data, get_onboarding_completed, get_dictation_settings, update_dictation_settings, get_voice_server_settings, update_voice_server_settings, set_onboarding_completed}``schemas.rs`.
- RPC `config.{get_config, update_model_settings, update_memory_settings, update_screen_intelligence_settings, update_runtime_settings, update_browser_settings, resolve_api_url, get_runtime_flags, set_browser_allow_all, workspace_onboarding_flag_exists, workspace_onboarding_flag_set, update_analytics_settings, get_analytics_settings, update_meet_settings, get_meet_settings, agent_server_status, reset_local_data, get_onboarding_completed, get_dictation_settings, update_dictation_settings, get_voice_server_settings, update_voice_server_settings, set_onboarding_completed}``schemas.rs`.
## Calls into
+1 -1
View File
@@ -29,7 +29,7 @@ pub use schema::{
CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig,
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
LocalAiConfig, MatrixConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
LocalAiConfig, MatrixConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
+32
View File
@@ -219,6 +219,11 @@ pub struct AnalyticsSettingsPatch {
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct MeetSettingsPatch {
pub auto_orchestrator_handoff: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct LocalAiSettingsPatch {
pub runtime_enabled: Option<bool>,
@@ -487,6 +492,33 @@ pub async fn load_and_apply_analytics_settings(
apply_analytics_settings(&mut config, update).await
}
/// Updates the Google Meet integration settings in the configuration.
pub async fn apply_meet_settings(
config: &mut Config,
update: MeetSettingsPatch,
) -> Result<RpcOutcome<serde_json::Value>, String> {
if let Some(enabled) = update.auto_orchestrator_handoff {
config.meet.auto_orchestrator_handoff = enabled;
}
config.save().await.map_err(|e| e.to_string())?;
let snapshot = snapshot_config_json(config)?;
Ok(RpcOutcome::new(
snapshot,
vec![format!(
"meet settings saved to {}",
config.config_path.display()
)],
))
}
/// Loads the configuration, applies meet settings updates, and saves it.
pub async fn load_and_apply_meet_settings(
update: MeetSettingsPatch,
) -> Result<RpcOutcome<serde_json::Value>, String> {
let mut config = load_config_with_timeout().await?;
apply_meet_settings(&mut config, update).await
}
/// Loads the configuration, applies browser settings updates, and saves it.
pub async fn load_and_apply_browser_settings(
update: BrowserSettingsPatch,
+42
View File
@@ -355,6 +355,48 @@ async fn apply_analytics_settings_updates_enabled() {
assert!(!cfg.observability.analytics_enabled);
}
#[tokio::test]
async fn apply_meet_settings_updates_handoff_flag() {
let tmp = tempdir().unwrap();
let mut cfg = tmp_config(&tmp);
// Default is OFF for a fresh config (issue #1299).
assert!(
!cfg.meet.auto_orchestrator_handoff,
"fresh config must start with auto_orchestrator_handoff=false"
);
// Flip ON.
let _ = apply_meet_settings(
&mut cfg,
MeetSettingsPatch {
auto_orchestrator_handoff: Some(true),
},
)
.await
.expect("apply on");
assert!(cfg.meet.auto_orchestrator_handoff);
// Flip OFF again — covers the off-after-on path.
let _ = apply_meet_settings(
&mut cfg,
MeetSettingsPatch {
auto_orchestrator_handoff: Some(false),
},
)
.await
.expect("apply off");
assert!(!cfg.meet.auto_orchestrator_handoff);
// No-op patch must not change the flag.
let prior = cfg.meet.auto_orchestrator_handoff;
let _ = apply_meet_settings(
&mut cfg,
MeetSettingsPatch {
auto_orchestrator_handoff: None,
},
)
.await
.expect("apply noop");
assert_eq!(prior, cfg.meet.auto_orchestrator_handoff);
}
#[tokio::test]
async fn get_config_snapshot_wraps_snapshot_in_rpc_outcome() {
let tmp = tempdir().unwrap();
+84
View File
@@ -0,0 +1,84 @@
//! Google Meet integration settings.
//!
//! Currently exposes a single privacy-relevant flag:
//! `auto_orchestrator_handoff` — when `true`, ending a Google Meet call
//! inside the OpenHuman webview hands the captured transcript to the
//! orchestrator agent, which may **proactively** execute tools (e.g. post
//! summaries to Slack, draft messages, schedule follow-ups). Default
//! `false` so the user must opt in before any external action fires.
//!
//! See issue tinyhumansai/openhuman#1299.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MeetConfig {
/// When `true`, the orchestrator agent receives the transcript of every
/// completed Google Meet call as a fresh chat thread and is invited to
/// take proactive actions on it (drafting messages, scheduling
/// follow-ups, etc.). When `false` (the default), transcripts still
/// land in memory but no auto-orchestrator handoff fires.
#[serde(default = "default_auto_orchestrator_handoff")]
pub auto_orchestrator_handoff: bool,
}
fn default_auto_orchestrator_handoff() -> bool {
false
}
impl Default for MeetConfig {
fn default() -> Self {
Self {
auto_orchestrator_handoff: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_disables_handoff() {
let cfg = MeetConfig::default();
assert!(
!cfg.auto_orchestrator_handoff,
"auto_orchestrator_handoff must default to false (privacy-conservative)"
);
}
#[test]
fn default_helper_returns_false() {
assert!(!default_auto_orchestrator_handoff());
}
#[test]
fn deserialize_missing_optional_fields_uses_defaults() {
let cfg: MeetConfig = serde_json::from_value(json!({})).unwrap();
assert!(
!cfg.auto_orchestrator_handoff,
"missing field must deserialize to false"
);
}
#[test]
fn deserialize_respects_explicit_handoff_flag() {
let cfg: MeetConfig = serde_json::from_value(json!({
"auto_orchestrator_handoff": true
}))
.unwrap();
assert!(cfg.auto_orchestrator_handoff);
}
#[test]
fn round_trip_preserves_handoff_flag() {
let original = MeetConfig {
auto_orchestrator_handoff: true,
};
let s = serde_json::to_string(&original).unwrap();
let back: MeetConfig = serde_json::from_str(&s).unwrap();
assert!(back.auto_orchestrator_handoff);
}
}
+2
View File
@@ -19,6 +19,7 @@ pub use load::{
user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID,
};
mod local_ai;
mod meet;
mod node;
mod observability;
mod proxy;
@@ -45,6 +46,7 @@ pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
pub use identity_cost::{CostConfig, ModelPricing};
pub use learning::{LearningConfig, ReflectionSource};
pub use local_ai::{LocalAiConfig, LocalAiUsage};
pub use meet::MeetConfig;
pub use node::NodeConfig;
pub use observability::ObservabilityConfig;
pub use proxy::{
+7
View File
@@ -157,6 +157,12 @@ pub struct Config {
#[serde(default)]
pub dictation: DictationConfig,
/// Google Meet integration settings — currently the
/// `auto_orchestrator_handoff` privacy gate (see
/// [`crate::openhuman::config::schema::MeetConfig`]).
#[serde(default)]
pub meet: MeetConfig,
/// Whether the user has completed the **React UI** onboarding flow.
///
/// Set by `OnboardingOverlay.tsx::handleDone` and the multi-step
@@ -288,6 +294,7 @@ impl Default for Config {
learning: LearningConfig::default(),
update: UpdateConfig::default(),
dictation: DictationConfig::default(),
meet: MeetConfig::default(),
onboarding_completed: false,
chat_onboarding_completed: false,
}
+92
View File
@@ -57,6 +57,11 @@ struct AnalyticsSettingsUpdate {
enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct MeetSettingsUpdate {
auto_orchestrator_handoff: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct LocalAiSettingsUpdate {
runtime_enabled: Option<bool>,
@@ -125,6 +130,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("workspace_onboarding_flag_set"),
schemas("update_analytics_settings"),
schemas("get_analytics_settings"),
schemas("update_meet_settings"),
schemas("get_meet_settings"),
schemas("agent_server_status"),
schemas("reset_local_data"),
schemas("get_onboarding_completed"),
@@ -198,6 +205,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("get_analytics_settings"),
handler: handle_get_analytics_settings,
},
RegisteredController {
schema: schemas("update_meet_settings"),
handler: handle_update_meet_settings,
},
RegisteredController {
schema: schemas("get_meet_settings"),
handler: handle_get_meet_settings,
},
RegisteredController {
schema: schemas("agent_server_status"),
handler: handle_agent_server_status,
@@ -507,6 +522,29 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"update_meet_settings" => ControllerSchema {
namespace: "config",
function: "update_meet_settings",
description:
"Update Google Meet integration settings (currently the auto-orchestrator-handoff privacy gate).",
inputs: vec![optional_bool(
"auto_orchestrator_handoff",
"When true, ending a Meet call hands the transcript to the orchestrator for proactive follow-up actions.",
)],
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
},
"get_meet_settings" => ControllerSchema {
namespace: "config",
function: "get_meet_settings",
description: "Read current Google Meet integration settings.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "auto_orchestrator_handoff",
ty: TypeSchema::Bool,
comment: "Whether the orchestrator handoff fires on Meet call end.",
required: true,
}],
},
"agent_server_status" => ControllerSchema {
namespace: "config",
function: "agent_server_status",
@@ -796,6 +834,60 @@ fn handle_get_analytics_settings(_params: Map<String, Value>) -> ControllerFutur
})
}
fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[config][rpc] update_meet_settings enter");
let update = match deserialize_params::<MeetSettingsUpdate>(params) {
Ok(u) => u,
Err(err) => {
log::warn!("[config][rpc] update_meet_settings invalid params: {err}");
return Err(err);
}
};
log::debug!(
"[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?}",
update.auto_orchestrator_handoff
);
let patch = config_rpc::MeetSettingsPatch {
auto_orchestrator_handoff: update.auto_orchestrator_handoff,
};
match config_rpc::load_and_apply_meet_settings(patch).await {
Ok(outcome) => {
log::debug!("[config][rpc] update_meet_settings ok");
to_json(outcome)
}
Err(err) => {
log::warn!("[config][rpc] update_meet_settings failed: {err}");
Err(err)
}
}
})
}
fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
log::debug!("[config][rpc] get_meet_settings enter");
let config = match config_rpc::load_config_with_timeout().await {
Ok(c) => c,
Err(err) => {
log::warn!("[config][rpc] get_meet_settings load failed: {err}");
return Err(err);
}
};
let auto_orchestrator_handoff = config.meet.auto_orchestrator_handoff;
log::debug!(
"[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff}"
);
let result = serde_json::json!({
"auto_orchestrator_handoff": auto_orchestrator_handoff,
});
to_json(RpcOutcome::new(
result,
vec!["meet settings read".to_string()],
))
})
}
fn handle_agent_server_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::agent_server_status()) })
}
+2
View File
@@ -40,6 +40,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() {
"workspace_onboarding_flag_set",
"update_analytics_settings",
"get_analytics_settings",
"update_meet_settings",
"get_meet_settings",
"agent_server_status",
"reset_local_data",
"get_onboarding_completed",
+10
View File
@@ -1835,6 +1835,16 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() {
Some(true),
"expected chatOnboardingCompleted=true from test config: {body}"
);
// #1299 — Meet auto-orchestrator handoff is the privacy gate that
// controls whether ending a Meet call hands the transcript to the
// orchestrator agent. Default is OFF on a fresh config so meeting
// notes never auto-broadcast to Slack #general etc. without consent.
assert_eq!(
body.get("meetAutoOrchestratorHandoff")
.and_then(Value::as_bool),
Some(false),
"expected meetAutoOrchestratorHandoff=false default: {body}"
);
let runtime = body.get("runtime").expect("expected runtime object");
assert!(