Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/desktop/src/main/__tests__/notifications-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import assert from 'node:assert/strict';
import {
isRunNotificationKind,
resolveNotificationContent,
resolveNotificationIncognito,
runNotificationCopy,
shouldRaiseRunNotification,
} from '../notifications-policy.js';
Expand Down Expand Up @@ -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,
);
});
21 changes: 20 additions & 1 deletion apps/desktop/src/main/notifications-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMainWindowController>;

Expand All @@ -35,6 +37,17 @@ interface NotificationsIpcDeps {
locale: Pick<DesktopLocaleAuthority, 'observe'>;
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;
}

/**
Expand All @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/notifications-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
}

/**
* 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<boolean> {
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
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof entry, { readiness: 'ready' }> =>
entry.readiness === 'ready',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not turn a reconnecting privacy authority into a non-incognito verdict

Reasonable disconnect race: an incognito Host delivers a completed/error event, the renderer queues notifications:runEnded, and that Host enters reconnecting while the handler awaits local settings. This ready-only filter removes it before any policy query. With zero ready entries (or only an ordinary local Host remaining), verdicts.some returns false, so an unfocused window can display the queued private title/body. The query-error catch cannot help because the Host was never queried.

The exact adapter/gate probe produces raised=true for zero-ready and local-ready + incognito-reconnecting cases. Keep a missing/unready relevant authority fail-closed; if needed pass the source scope through the existing notification IPC so the main process can resolve the right Host. Cover this actual boot decision, rather than only an injected resolver that throws.

中文

合理断连路径:隐身完成事件已触发通知 IPC,main 等待 settings 时 Host 变为 reconnecting,被 ready filter 排除,空 verdict 或仅普通本地 Host 都返回 false,后台窗口因此可展示私密标题/正文。未查询就不会触发 catch。探针确认 raised=true。相关权威缺失或未 ready 应保守抑制,必要时沿现有 IPC 传来源;测试应经过实际 boot adapter。

);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not drop a reconnecting Host from the privacy decision

A terminal event can already have been forwarded to the renderer when its Host disconnects; the renderer then invokes notifications:runEnded with the title/preview while the manager publishes that Host as reconnecting (runtime-host-desktop-manager.ts:1365-1370). This filter silently excludes its privacy authority. Executing this exact adapter with only a reconnecting Host returns false; a reconnecting private Host plus another ready non-private Host also returns false. Promise.all([]) does not reject, so resolveNotificationIncognito never reaches its fail-closed catch and the native banner can expose the content. The IPC payload has no originating Host identity, so another ready Host cannot authorize this message. Preserve a suppressing verdict when the originating privacy authority cannot be queried (and cover the real adapter readiness transition).

const verdicts = await Promise.all(
ready.map(async (entry) =>
(await entry.candidate.client.queryRuntimePolicy()).policy.privacy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Exclude unrelated guest policy failures from local notification decisions

Normal supported path: mount a shared Session and then complete a turn in a local non-incognito Session. mountGuest registers the ready guest in the same manager entries, but SESSION_GUEST_OPERATION_GRANTS does not include runtime.policy.query. This Promise.all therefore rejects on every notification while that guest is mounted; the resolver catches it as incognito and suppresses all local notifications too.

The actual adapter probe with a local false verdict and a ready guest rejecting policy returns raised=false. Scope the query to the notification's legitimate authority; do not grant guests whole-Host policy access or simply ignore failures of the source Host. A local + mounted-guest regression should preserve the local notification.

中文

正常挂载共享 Session 后,guest 同样进入 ready entries,但其权限不包含 runtime.policy.query,因此 Promise.all 拒绝,所有普通本地通知也被当隐身取消。探针复现 raised=false。应按通知来源隔离权威,不给 guest 扩整机权限,也不能忽略来源 Host 的失败。

.incognitoActive,
),
);
return verdicts.some((active) => active);
},
},
});

const sessionCopyOwnerProcessId = randomUUID();
Expand Down