Skip to content

Commit 103645b

Browse files
committed
feat(desktop): scope Settings to Runtime Hosts
Host-owned Settings previously followed the default Host, making multi-host configuration ambiguous. Add explicit target selection while keeping client preferences and bot configuration Desktop-owned. Route reads, writes, events, connections, and credentials through the verified target scope without changing the default Host or existing Sessions. Generated-by: OpenAI Codex
1 parent 93c65c7 commit 103645b

32 files changed

Lines changed: 1006 additions & 439 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
createDefaultSettings,
5+
mergeSettings,
6+
type UpdateAppSettingsInput,
7+
} from "@maka/core/settings";
8+
import { registerClientSettingsIpc } from "../client-settings-ipc-main.js";
9+
10+
test("client settings updates cannot mutate Runtime Host policy", async () => {
11+
const handlers = new Map<string, (...args: unknown[]) => unknown>();
12+
let settings = createDefaultSettings();
13+
let storedPatch: UpdateAppSettingsInput | undefined;
14+
let applied = 0;
15+
registerClientSettingsIpc({
16+
ipcMain: {
17+
handle(channel, listener) {
18+
handlers.set(channel, listener as (...args: unknown[]) => unknown);
19+
},
20+
},
21+
settingsStore: {
22+
get: async () => settings,
23+
update: async (patch: UpdateAppSettingsInput) => {
24+
storedPatch = patch;
25+
settings = mergeSettings(settings, patch);
26+
return settings;
27+
},
28+
} as never,
29+
apply: async () => {
30+
applied += 1;
31+
},
32+
});
33+
34+
const result = await handlers.get("settings:client:update")?.({}, {
35+
appearance: { theme: "dark" },
36+
chatDefaults: { permissionMode: "bypass" },
37+
});
38+
39+
assert.deepEqual(storedPatch, { appearance: { theme: "dark" } });
40+
assert.equal((result as { settings: typeof settings }).settings.appearance.theme, "dark");
41+
assert.equal(settings.chatDefaults.permissionMode, "ask");
42+
assert.equal(applied, 1);
43+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {
2+
AppSettings,
3+
UpdateAppSettingsInput,
4+
UpdateAppSettingsResult,
5+
} from "@maka/core/settings";
6+
import type { SettingsStore } from "@maka/storage";
7+
import type { IpcMain } from "electron";
8+
import {
9+
clientOwnedSettingsPatch,
10+
hasSettingsPatch,
11+
} from "../shared/settings-ownership.js";
12+
import {
13+
buildSettingsUpdateResult,
14+
maskAppSettings,
15+
} from "./settings-ipc-helpers.js";
16+
17+
export function registerClientSettingsIpc(deps: {
18+
readonly ipcMain: Pick<IpcMain, "handle">;
19+
readonly settingsStore: SettingsStore;
20+
readonly apply: (settings: AppSettings) => Promise<void>;
21+
}): void {
22+
deps.ipcMain.handle("settings:client:get", async () =>
23+
maskAppSettings(await deps.settingsStore.get()),
24+
);
25+
deps.ipcMain.handle(
26+
"settings:client:update",
27+
async (
28+
_event,
29+
patch: UpdateAppSettingsInput,
30+
): Promise<UpdateAppSettingsResult> => {
31+
const clientPatch = clientOwnedSettingsPatch(patch);
32+
const settings = hasSettingsPatch(clientPatch)
33+
? await deps.settingsStore.update(clientPatch)
34+
: await deps.settingsStore.get();
35+
await deps.apply(settings);
36+
return buildSettingsUpdateResult(maskAppSettings(settings), clientPatch);
37+
},
38+
);
39+
}

apps/desktop/src/main/runtime-host-boot.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import { registerRuntimeHostConfigIpc } from "./runtime-host-config-ipc-main.js"
9090
import { createCapabilityRevisionPublisher } from "./runtime-host-capability-revision-publisher.js";
9191
import { buildClientSettingsTools } from "./client-settings-tools.js";
9292
import { createClientSettingsEffects } from "./client-settings-effects.js";
93+
import { registerClientSettingsIpc } from "./client-settings-ipc-main.js";
9394
import { startClientSettingsWatcher } from "./client-settings-watcher.js";
9495
import { registerRuntimeHostGitHubCopilotIpc } from "./runtime-host-github-copilot-ipc-main.js";
9596
import { registerRuntimeHostArtifactsIpc } from "./runtime-host-artifacts-ipc-main.js";
@@ -359,7 +360,7 @@ const botRegistry = new BotRegistry({
359360
.catch((error) => console.error("[runtime-host] bot message failed:", error));
360361
},
361362
onStatusChange: (status) => {
362-
sendActiveRuntimeHostEvent("settings:bots:statusChanged", status);
363+
mainWindowController.send("settings:bots:statusChanged", status);
363364
},
364365
});
365366
const clientSettingsEffects = createClientSettingsEffects({
@@ -370,8 +371,10 @@ const clientSettingsEffects = createClientSettingsEffects({
370371
applyBotSettings: useBotOnboardingFixture
371372
? async () => undefined
372373
: (settings) => botRegistry.applySettings(settings),
373-
emitExternalChanged: () =>
374-
sendActiveRuntimeHostEvent("settings:externalChanged", { ts: Date.now() }),
374+
emitExternalChanged: () => {
375+
mainWindowController.send("settings:clientChanged");
376+
sendActiveRuntimeHostEvent("settings:externalChanged", { ts: Date.now() });
377+
},
375378
});
376379
const clientSettingsTools = buildClientSettingsTools({
377380
read: () => settingsStore.get(),
@@ -877,23 +880,6 @@ function registerHostClientIpc(
877880
updateRuntimeHostSettings(settingsIpcDeps, patch),
878881
emitConnectionsChanged: emitTargetConnectionListChanged,
879882
});
880-
const candidateSettingsBotsIpc = registerSettingsBotsIpc({
881-
ipcMain: scopedIpc,
882-
settingsStore,
883-
botRegistry,
884-
applySettingsRuntimeEffects: async (settings) => {
885-
await clientSettingsEffects.apply(settings, true);
886-
},
887-
productVersion: app.getVersion(),
888-
openExternal: (url) => shell.openExternal(url),
889-
...(useBotOnboardingFixture
890-
? {
891-
botOnboardingAdapters: createE2eFixtureBotOnboardingAdapters(),
892-
botOnboardingReadChannelStatus: () => ({ running: true }),
893-
}
894-
: {}),
895-
});
896-
settingsBotsIpc = candidateSettingsBotsIpc;
897883
registerRuntimeHostPermissionsIpc({
898884
ipcMain: scopedIpc,
899885
client,
@@ -1061,10 +1047,6 @@ function registerHostClientIpc(
10611047
unsubscribeSessionCatalogChanges();
10621048
unsubscribeProjectCatalogChanges();
10631049
unsubscribeScheduledTaskChanges();
1064-
candidateSettingsBotsIpc.dispose();
1065-
if (settingsBotsIpc === candidateSettingsBotsIpc) {
1066-
settingsBotsIpc = undefined;
1067-
}
10681050
runtimePolicyTargets.delete(target);
10691051
if (runtimePolicyTargetsByEpoch.get(scope.targetEpoch) === targetContext) {
10701052
runtimePolicyTargetsByEpoch.delete(scope.targetEpoch);
@@ -1089,6 +1071,29 @@ function registerPersistentClientIpc(): void {
10891071
});
10901072
registerMarkdownSaveIpc({ ipcMain, mainWindowController });
10911073
registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService);
1074+
registerClientSettingsIpc({
1075+
ipcMain,
1076+
settingsStore,
1077+
apply: async (settings) => {
1078+
await clientSettingsEffects.apply(settings, true);
1079+
},
1080+
});
1081+
settingsBotsIpc = registerSettingsBotsIpc({
1082+
ipcMain,
1083+
settingsStore,
1084+
botRegistry,
1085+
applySettingsRuntimeEffects: async (settings) => {
1086+
await clientSettingsEffects.apply(settings, true);
1087+
},
1088+
productVersion: app.getVersion(),
1089+
openExternal: (url) => shell.openExternal(url),
1090+
...(useBotOnboardingFixture
1091+
? {
1092+
botOnboardingAdapters: createE2eFixtureBotOnboardingAdapters(),
1093+
botOnboardingReadChannelStatus: () => ({ running: true }),
1094+
}
1095+
: {}),
1096+
});
10921097
ipcMain.handle("settings:usageStats", async (_event, range?: UsageRange) =>
10931098
projectDesktopUsageStats(
10941099
{ hostId: localRuntimeHostId },

apps/desktop/src/main/runtime-host-settings-ipc-main.ts

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
handleReconnectableRead,
2626
type ReconnectableReadIpcMain,
2727
} from "./ipc-reconnect-policy.js";
28+
import {
29+
clientOwnedSettingsPatch,
30+
hasSettingsPatch,
31+
} from "../shared/settings-ownership.js";
2832

2933
type RuntimeHostSettingsClient = Pick<
3034
DesktopRuntimeHostClient,
@@ -117,8 +121,8 @@ export async function updateRuntimeHostSettings(
117121
patch: UpdateAppSettingsInput,
118122
): Promise<AppSettings> {
119123
await applyHostPatch(deps.client, patch);
120-
const clientPatch = toClientOwnedPatch(patch);
121-
const local = hasPatch(clientPatch)
124+
const clientPatch = clientOwnedSettingsPatch(patch);
125+
const local = hasSettingsPatch(clientPatch)
122126
? await deps.settingsStore.update(clientPatch)
123127
: await deps.settingsStore.get();
124128
await deps.applyClientSettings(local);
@@ -368,33 +372,3 @@ function withoutSecret(
368372
const { password: _password, ...value } = patch;
369373
return value;
370374
}
371-
372-
function toClientOwnedPatch(
373-
patch: UpdateAppSettingsInput,
374-
): UpdateAppSettingsInput {
375-
const personalization =
376-
patch.personalization?.uiLocale === undefined &&
377-
patch.personalization?.selectedPetId === undefined
378-
? undefined
379-
: {
380-
...(patch.personalization.uiLocale === undefined
381-
? {}
382-
: { uiLocale: patch.personalization.uiLocale }),
383-
...(patch.personalization.selectedPetId === undefined
384-
? {}
385-
: { selectedPetId: patch.personalization.selectedPetId }),
386-
};
387-
return {
388-
...(patch.botChat ? { botChat: patch.botChat } : {}),
389-
...(patch.usage ? { usage: patch.usage } : {}),
390-
...(patch.appearance ? { appearance: patch.appearance } : {}),
391-
...(personalization ? { personalization } : {}),
392-
...(patch.notifications ? { notifications: patch.notifications } : {}),
393-
...(patch.projects ? { projects: patch.projects } : {}),
394-
...(patch.system ? { system: patch.system } : {}),
395-
};
396-
}
397-
398-
function hasPatch(patch: UpdateAppSettingsInput): boolean {
399-
return Object.keys(patch).length > 0;
400-
}

0 commit comments

Comments
 (0)