From ba88d8cfc306bbbab3e32f23ff01f4f92ac0cbad Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 7 May 2026 06:13:21 +0530 Subject: [PATCH] fix(webview/meet): gate orchestrator handoff on user opt-in (#1299) (#1310) Co-authored-by: Claude Opus 4.7 (1M context) --- .../settings/panels/PrivacyPanel.tsx | 48 ++++++- .../panels/__tests__/PrivacyPanel.test.tsx | 27 +++- app/src/lib/coreState/__tests__/store.test.ts | 1 + app/src/lib/coreState/store.ts | 11 ++ app/src/providers/CoreStateProvider.tsx | 21 +++ .../CoreStateProvider.identityFlip.test.tsx | 1 + .../__tests__/CoreStateProvider.test.tsx | 66 +++++++++ ...viewAccountService.meetHandoffGate.test.ts | 126 ++++++++++++++++++ app/src/services/coreStateApi.ts | 15 ++- app/src/services/webviewAccountService.ts | 49 ++++++- .../store/__tests__/socketSelectors.test.ts | 1 + app/src/test/setup.ts | 9 ++ app/src/utils/tauriCommands/config.test.ts | 43 ++++++ app/src/utils/tauriCommands/config.ts | 23 ++++ src/openhuman/app_state/ops.rs | 9 +- src/openhuman/config/README.md | 2 +- src/openhuman/config/mod.rs | 2 +- src/openhuman/config/ops.rs | 32 +++++ src/openhuman/config/ops_tests.rs | 42 ++++++ src/openhuman/config/schema/meet.rs | 84 ++++++++++++ src/openhuman/config/schema/mod.rs | 2 + src/openhuman/config/schema/types.rs | 7 + src/openhuman/config/schemas.rs | 92 +++++++++++++ src/openhuman/config/schemas_tests.rs | 2 + tests/json_rpc_e2e.rs | 10 ++ 25 files changed, 715 insertions(+), 10 deletions(-) create mode 100644 app/src/services/__tests__/webviewAccountService.meetHandoffGate.test.ts create mode 100644 src/openhuman/config/schema/meet.rs diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx index 7a86f40c2..e41edc628 100644 --- a/app/src/components/settings/panels/PrivacyPanel.tsx +++ b/app/src/components/settings/panels/PrivacyPanel.tsx @@ -35,8 +35,9 @@ const KIND_BADGE_CLASS: Record = { 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([]); 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 (
{
+ {/* Meeting Follow-ups Section (#1299) */} +
+

+ Meeting follow-ups +

+
+
+
+

+ Auto-handoff Google Meet transcripts to the orchestrator +

+

+ When a Google Meet call ends, OpenHuman’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. +

+
+ +
+
+
+ {/* Info Box */}
diff --git a/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx index cb7e78bc6..7d55e15d0 100644 --- a/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx @@ -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(); + + 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); + }); }); }); diff --git a/app/src/lib/coreState/__tests__/store.test.ts b/app/src/lib/coreState/__tests__/store.test.ts index bbd0fe0d8..497600908 100644 --- a/app/src/lib/coreState/__tests__/store.test.ts +++ b/app/src/lib/coreState/__tests__/store.test.ts @@ -10,6 +10,7 @@ function makeSnapshot(overrides: Partial = {}): 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, diff --git a/app/src/lib/coreState/store.ts b/app/src/lib/coreState/store.ts index 6d0024205..77078e71f 100644 --- a/app/src/lib/coreState/store.ts +++ b/app/src/lib/coreState/store.ts @@ -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 }, }; diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index fd6089671..7fc6b69f3 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -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; refreshTeamInvites: (teamId: string) => Promise; setAnalyticsEnabled: (enabled: boolean) => Promise; + setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise; setOnboardingCompletedFlag: (value: boolean) => Promise; setEncryptionKey: (value: string | null) => Promise; setPrimaryWalletAddress: (value: string | null) => Promise; @@ -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, diff --git a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx index 43f62d05d..0e58f37b8 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -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 }, }, diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index dba627f3d..2c460619f 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -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( + + { + ctx = next; + }} + /> + + ); + + 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( + + { + ctx = next; + }} + /> + + ); + + 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, + }); + }); }); diff --git a/app/src/services/__tests__/webviewAccountService.meetHandoffGate.test.ts b/app/src/services/__tests__/webviewAccountService.meetHandoffGate.test.ts new file mode 100644 index 000000000..f4e1ecd1a --- /dev/null +++ b/app/src/services/__tests__/webviewAccountService.meetHandoffGate.test.ts @@ -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[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[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[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[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'); + }); +}); diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index a76be69aa..70902f920 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -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 => 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 => { diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index e3f337feb..00429ade2 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -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 +): Promise { + 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 ({ .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(), })); diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index bea663a4c..4c14dd1c9 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -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('./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); + }); + }); }); diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 84576c1fa..ead2c84f3 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -204,6 +204,29 @@ export async function openhumanGetAnalyticsSettings(): Promise< }); } +export async function openhumanUpdateMeetSettings(update: { + auto_orchestrator_handoff?: boolean; +}): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + 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>({ + method: 'openhuman.config_get_meet_settings', + }); +} + export async function openhumanGetRuntimeFlags(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index efc9b8c2a..e6bfe696f 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -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, 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, 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, }, diff --git a/src/openhuman/config/README.md b/src/openhuman/config/README.md index 5f801c235..f65bea606 100644 --- a/src/openhuman/config/README.md +++ b/src/openhuman/config/README.md @@ -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 diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 452d69234..6c48bca5b 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -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, diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index b28e19e41..cecbd0671 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -219,6 +219,11 @@ pub struct AnalyticsSettingsPatch { pub enabled: Option, } +#[derive(Debug, Clone, Default)] +pub struct MeetSettingsPatch { + pub auto_orchestrator_handoff: Option, +} + #[derive(Debug, Clone, Default)] pub struct LocalAiSettingsPatch { pub runtime_enabled: Option, @@ -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, 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, 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, diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 40cfac010..6449e6956 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -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(); diff --git a/src/openhuman/config/schema/meet.rs b/src/openhuman/config/schema/meet.rs new file mode 100644 index 000000000..55590beee --- /dev/null +++ b/src/openhuman/config/schema/meet.rs @@ -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); + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 44e5a749c..82492a321 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -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::{ diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index d5f0bca83..118548106 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -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, } diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 252cab35c..f3449d2bb 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -57,6 +57,11 @@ struct AnalyticsSettingsUpdate { enabled: Option, } +#[derive(Debug, Deserialize)] +struct MeetSettingsUpdate { + auto_orchestrator_handoff: Option, +} + #[derive(Debug, Deserialize)] struct LocalAiSettingsUpdate { runtime_enabled: Option, @@ -125,6 +130,8 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFutur }) } +fn handle_update_meet_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!("[config][rpc] update_meet_settings enter"); + let update = match deserialize_params::(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) -> 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) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index 27cc9e296..56f40fce9 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -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", diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 6b3fe246f..bcb9f0bb2 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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!(