From acdcc9d90e4534b4401b75c24e52e93a337a71a9 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Tue, 8 Sep 2026 01:23:34 +0530 Subject: [PATCH] fix(desktop): gate notification content on Runtime Host privacy policy (#4981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run-ended notification gated content-bearing banners on settings.privacy.incognitoActive, but the local settings copy never receives privacy updates: clientOwnedSettingsPatch excludes the section and projection keeps the host's copy. After incognito was enabled, the stale local copy kept reading false and the banner kept exposing the session title + reply preview outside the app. registerNotificationsIpc now takes an optional privacyAuthority. When provided, resolveNotificationIncognito() reads incognito from the authority and the local copy is never consulted; when the authority rejects, the notification is suppressed rather than risked (fail-closed). The authority lives in notifications-policy.ts beside the rest of the gating logic so it stays unit-testable under plain node --test; boot passes an adapter that queries every ready host's runtime.policy and suppresses if any holds incognito — the same client.queryRuntimePolicy().policy.privacy path search-ipc already uses. Callers without the dep keep the existing local-copy behavior. --- .../__tests__/notifications-policy.test.ts | 29 +++++++++++++++++++ .../src/main/notifications-ipc-main.ts | 21 +++++++++++++- apps/desktop/src/main/notifications-policy.ts | 23 +++++++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 20 +++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/notifications-policy.test.ts b/apps/desktop/src/main/__tests__/notifications-policy.test.ts index a20d0c1300..26550bd25f 100644 --- a/apps/desktop/src/main/__tests__/notifications-policy.test.ts +++ b/apps/desktop/src/main/__tests__/notifications-policy.test.ts @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { isRunNotificationKind, resolveNotificationContent, + resolveNotificationIncognito, runNotificationCopy, shouldRaiseRunNotification, } from '../notifications-policy.js'; @@ -88,3 +89,31 @@ it('sanitizes renderer content, caps it, and falls back per field', () => { { title: '出错的会话', body: erroredFallback.body }, ); }); + +it('reads incognito from the Runtime Host authority, failing closed', async () => { + // Authority verdict wins over the local copy in both directions: the + // local copy never receives privacy updates, so a stale `true` must not + // suppress when the host says otherwise, and a stale `false` must not + // expose when incognito is actually on. + assert.equal( + await resolveNotificationIncognito(false, { isIncognitoActive: async () => true }), + true, + ); + assert.equal( + await resolveNotificationIncognito(true, { isIncognitoActive: async () => false }), + false, + ); + // No authority: the existing local-copy gate applies unchanged. + assert.equal(await resolveNotificationIncognito(true, undefined), true); + assert.equal(await resolveNotificationIncognito(false, undefined), false); + // An unreachable authority suppresses rather than risking exposure of + // the session title + reply preview outside the app. + assert.equal( + await resolveNotificationIncognito(false, { + isIncognitoActive: async () => { + throw new Error('host unreachable'); + }, + }), + true, + ); +}); diff --git a/apps/desktop/src/main/notifications-ipc-main.ts b/apps/desktop/src/main/notifications-ipc-main.ts index be06ab7763..103e363fa1 100644 --- a/apps/desktop/src/main/notifications-ipc-main.ts +++ b/apps/desktop/src/main/notifications-ipc-main.ts @@ -24,8 +24,10 @@ import type { DesktopLocaleAuthority } from './desktop-locale-authority.js'; import { isRunNotificationKind, resolveNotificationContent, + resolveNotificationIncognito, shouldRaiseRunNotification, } from './notifications-policy.js'; +import type { PrivacyAuthority } from './notifications-policy.js'; type MainWindowController = ReturnType; @@ -35,6 +37,17 @@ interface NotificationsIpcDeps { locale: Pick; mainWindowController: MainWindowController; e2e: boolean; + /** + * Runtime Host privacy authority (#4981). The local settings copy never + * receives privacy updates (`clientOwnedSettingsPatch` excludes the + * section, and projection keeps the host's copy), so gating + * content-bearing notifications on `settings.privacy.incognitoActive` + * can read stale data and expose the session title + reply preview + * after incognito is enabled. When provided, its verdict wins; when it + * rejects, the notification is suppressed rather than risked + * (fail-closed); when absent, the existing local-copy gate applies. + */ + privacyAuthority?: PrivacyAuthority | undefined; } /** @@ -58,11 +71,17 @@ export function registerNotificationsIpc(deps: NotificationsIpcDeps): void { // Read the toggle lazily so a mid-session settings change takes // effect on the very next turn without any cache invalidation. const settings = await deps.settingsStore.get(); + let incognito: boolean; + if (deps.privacyAuthority) { + incognito = await resolveNotificationIncognito(false, deps.privacyAuthority); + } else { + incognito = settings.privacy.incognitoActive; + } const gate = { enabled: settings.notifications.runComplete, supported, windowFocused: deps.mainWindowController.isFocused(), - incognito: settings.privacy.incognitoActive, + incognito, e2e: deps.e2e, }; if (!shouldRaiseRunNotification(gate)) return; diff --git a/apps/desktop/src/main/notifications-policy.ts b/apps/desktop/src/main/notifications-policy.ts index 32da6335e4..5b42eafe1f 100644 --- a/apps/desktop/src/main/notifications-policy.ts +++ b/apps/desktop/src/main/notifications-policy.ts @@ -126,6 +126,29 @@ function sanitizeLine(value: unknown, max: number): string { return `${collapsed.slice(0, max - 1).trimEnd()}…`; } +/** Resolves whether any connected host currently holds incognito. */ +export interface PrivacyAuthority { + isIncognitoActive(): Promise; +} + +/** + * Reads incognito from the authority (#4981). The local settings copy never + * receives privacy updates, so its value must not decide content-bearing + * notifications. A rejecting authority suppresses the notification rather + * than risking exposure (fail-closed). + */ +export async function resolveNotificationIncognito( + settingsIncognitoActive: boolean, + privacyAuthority: PrivacyAuthority | undefined, +): Promise { + if (!privacyAuthority) return settingsIncognitoActive; + try { + return await privacyAuthority.isIncognitoActive(); + } catch { + return true; + } +} + /** * Final notification text: prefer the renderer's session name + reply * preview, falling back per-field to the generic copy when a field is diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bfbaa7b997..b3e6649e70 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1013,6 +1013,26 @@ registerNotificationsIpc({ locale: desktopLocale, mainWindowController, e2e: isE2e, + // Privacy state is Host-owned: the local settings copy never receives + // privacy updates, so the notification gate asks the authority instead + // of trusting the stale local copy (#4981). Any ready host holding + // incognito suppresses the banner; an unreachable authority does too. + privacyAuthority: { + isIncognitoActive: async () => { + const entries = runtimeHostManager?.entries() ?? []; + const ready = entries.filter( + (entry): entry is Extract => + entry.readiness === 'ready', + ); + const verdicts = await Promise.all( + ready.map(async (entry) => + (await entry.candidate.client.queryRuntimePolicy()).policy.privacy + .incognitoActive, + ), + ); + return verdicts.some((active) => active); + }, + }, }); const sessionCopyOwnerProcessId = randomUUID();