Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ Workspace data lives under Electron `userData` by default:
artifacts/
```

- When editing `settings.json` or `mcp.json` by hand, save the file as UTF-8 (preferably without a BOM). A UTF-8 BOM is accepted. Maka does not guess other encodings for files without a BOM or automatically convert UTF-16; explicitly convert those files to UTF-8 in your editor before using them.
- API keys and similar secrets are a local plaintext file (`credential-vault.json`), readable only by your OS account. The renderer never sees them.
- Tools that write files or run a shell must pass the sandbox boundary first.
- `runtime.sqlite` is the live record. Older JSONL transcripts and Electron `safeStorage` credential files are not imported; an upgraded workspace can show empty threads, and those credentials must be entered again.
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Workspace 数据默认放在 Electron `userData` 下:
artifacts/
```

- 手动编辑 `settings.json` 或 `mcp.json` 时,请保存为 UTF-8(建议不带 BOM;也支持 UTF-8 BOM)。Maka 不会猜测无 BOM 文件的其他编码,也不会自动转换 UTF-16;请先在编辑器中显式转换为 UTF-8,再使用这些文件。
- API key 一类的机密存在本地明文文件(`credential-vault.json`),只有你的系统账号能读。界面进程拿不到明文。
- 写文件、跑 Shell 的工具必须先过沙箱边界。
- `runtime.sqlite` 是当前生效的那份记录。更早的 JSONL transcript 和 Electron `safeStorage` 凭据不会导入;升级后会话可能是空的,那些凭据需要重新填写。
Expand Down
69 changes: 64 additions & 5 deletions apps/desktop/src/main/__tests__/client-settings-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ test('applies each client settings snapshot once across local writes and file wa
observeLocale: () => undefined,
emitExternalChanged: () => {
rendererEvents += 1;
return true;
},
});

assert.equal(await effects.refresh(false), true);
assert.equal(await effects.refresh(true), false); // Startup establishes the renderer baseline.
assert.equal(await effects.refresh(true), false);

current = {
Expand All @@ -65,6 +67,37 @@ test('applies each client settings snapshot once across local writes and file wa
assert.deepEqual(appIcons, []);
});

test('silent refreshes retain an un-emitted renderer change without repeating effects', async () => {
let current = createDefaultSettings();
const keepAwake: boolean[] = [];
const emittedLocales: string[] = [];
const effects = createClientSettingsEffects({
settingsStore: { get: async () => current },
applyWorkHub: async () => undefined,
applyKeepSystemAwake: async (enabled) => { keepAwake.push(enabled); },
applyBotSettings: async () => undefined,
applyAppIcon: async () => undefined,
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => { emittedLocales.push(current.personalization.uiLocale); return true; },
});
await effects.refresh(false);
current = {
...current,
system: { keepSystemAwake: true },
personalization: { ...current.personalization, uiLocale: 'zh-CN' },
};
assert.equal(await effects.refresh(false), true);
assert.equal(await effects.refresh(false), false);
assert.deepEqual(emittedLocales, []);
// A later write supersedes the silently applied snapshot before emission.
current = { ...current, personalization: { ...current.personalization, uiLocale: 'zh-TW' } };
assert.equal(await effects.refresh(true), true);
assert.equal(await effects.refresh(true), false);
assert.deepEqual(emittedLocales, ['zh-TW']);
assert.deepEqual(keepAwake, [false, true]);
});

test('applies a chosen app icon once, and again only when the choice changes', async () => {
let current = createDefaultSettings();
const appIcons: string[] = [];
Expand All @@ -78,7 +111,7 @@ test('applies a chosen app icon once, and again only when the choice changes', a
},
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
emitExternalChanged: () => true,
});

await effects.refresh(false);
Expand Down Expand Up @@ -115,7 +148,7 @@ test('an OS appearance flip re-applies the icon without any setting changing', a
},
systemPrefersDark: () => systemDark,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
emitExternalChanged: () => true,
});

await effects.refresh(false);
Expand Down Expand Up @@ -152,7 +185,7 @@ test('with one icon for both appearances a theme flip changes nothing', async ()
},
systemPrefersDark: () => systemDark,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
emitExternalChanged: () => true,
});

await effects.refresh(false);
Expand All @@ -178,7 +211,7 @@ test('an explicit dark preference ignores what the OS reports', async () => {
},
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
emitExternalChanged: () => true,
});
await effects.refresh(false);
assert.deepEqual(applied, ['ink']);
Expand All @@ -195,10 +228,36 @@ test('applies WorkHub enable state from the supplied snapshot without another st
applyAppIcon: async () => undefined,
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
emitExternalChanged: () => true,
});
const settings = createDefaultSettings();
await effects.apply({ ...settings, workHub: { enabled: true } }, true);
await effects.apply({ ...settings, workHub: { enabled: false } }, true);
assert.deepEqual(applied, [true, false]);
});


test('an unavailable renderer does not consume a changed settings fingerprint', async () => {
let current = createDefaultSettings();
let available = false;
let emitted = 0;
const effects = createClientSettingsEffects({
settingsStore: { get: async () => current },
applyWorkHub: async () => undefined,
applyKeepSystemAwake: async () => undefined,
applyBotSettings: async () => undefined,
applyAppIcon: async () => undefined,
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => { if (!available) return false; emitted += 1; return true; },
});
await effects.refresh(false);
current = { ...current, system: { keepSystemAwake: true } };
assert.equal(await effects.refresh(true), true); // Effects changed without an event.
assert.equal(await effects.refresh(true), false);
assert.equal(emitted, 0);
available = true;
assert.equal(await effects.refresh(true), true); // Only the pending event changes.
assert.equal(await effects.refresh(true), false);
assert.equal(emitted, 1);
});
49 changes: 45 additions & 4 deletions apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import assert from 'node:assert/strict';
import fs, { mkdtemp, readFile, rm } from 'node:fs/promises';
import fs, { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -33,7 +33,7 @@ import {
} from '@maka/storage/mcp-config-store';
import { registerMcpIpcMain, type McpIpcMainDeps } from '../mcp-ipc-main.js';
import { getMcpCopy } from '../../renderer/locales/mcp-copy.js';
import { mcpWriteFailureMessage } from '../../renderer/features/module-hub/testing.js';
import { mcpConfigFailureMessage, unwrapMcpIpcResult } from '../../renderer/features/module-hub/testing.js';

test('MCP remove reconciles a live manager after the real store publishes then fails directory sync', {
skip: process.platform === 'win32',
Expand Down Expand Up @@ -66,7 +66,7 @@ test('MCP remove reconciles a live manager after the real store publishes then f
assert.ok(error instanceof AtomicFileWriteCommitUnknownError);
assert.equal(error.published, true);
assert.equal(error.cause, fault.error);
assert.equal(mcpWriteFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeDurabilityUnknown);
assert.equal(mcpConfigFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeDurabilityUnknown);
return true;
});
assert.deepEqual(await diskConfig(root), { version: MCP_CONFIG_VERSION, mcpServers: {} });
Expand Down Expand Up @@ -126,7 +126,7 @@ for (const phase of ['read', 'sync', 'emit'] as const) {
assert.match(error.message, /out of sync/u);
assert.equal(error.cause, tracked.error());
assert.deepEqual(error.errors, [tracked.error(), reconciliationError]);
assert.equal(mcpWriteFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeOutOfSync);
assert.equal(mcpConfigFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeOutOfSync);
return true;
});
assert.ok((await diskConfig(root)).mcpServers.fixture);
Expand Down Expand Up @@ -243,3 +243,44 @@ function mutationHarness(t: TestContext, store: McpConfigStore, overrides: Parti
},
};
}

test('MCP IPC transports corrupt-file details for reads and every mutation without exposing parser secrets', async (t) => {
const { root, store } = await fixtureStore(t);
const path = join(root, 'mcp.json');
const source = 'sk-live-SECRET';
assert.throws(() => JSON.parse(source), (error) => {
assert.ok(error instanceof SyntaxError && error.message.includes(source));
return true;
});
await writeFile(path, source);
const ipc = mutationHarness(t, store);
const calls: [string, ...unknown[]][] = [
['mcp:getConfig'],
['mcp:importConfig', '{"new":{"command":"unused"}}'],
['mcp:add', 'new', { command: 'unused' }],
['mcp:update', 'old', { command: 'unused' }, { command: 'old' }],
['mcp:setEnabled', 'old', true],
['mcp:remove', 'old'],
];
for (const [channel, ...args] of calls) {
// Electron serializes the fulfilled value, not custom Error fields.
const result: unknown = structuredClone(await ipc.invoke(channel, ...args));
assert.deepEqual(result, { kind: 'invalid-mcp-config-file', path });
assert.equal(JSON.stringify(result).includes(source), false);
for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) {
const copy = getMcpCopy(locale);
assert.throws(() => unwrapMcpIpcResult(result), (error) => {
const wrapped = new Error('Runtime Host action failed', { cause: error });
assert.equal(mcpConfigFailureMessage(wrapped, copy), copy.errors.invalidConfigFile(path));
return true;
});
}
assert.equal(await readFile(path, 'utf8'), source);
}
assert.deepEqual(await ipc.invoke('mcp:importConfig', source), {
status: 'invalid', reason: 'invalid-json',
}, 'pasted invalid JSON remains an import validation result');
assert.deepEqual(ipc.synced, []);
assert.deepEqual(ipc.retired, []);
assert.deepEqual(ipc.emitted, []);
});
35 changes: 30 additions & 5 deletions apps/desktop/src/main/__tests__/mcp-page-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
mcpConfigFromDraft,
mcpDraftProtocolPreference,
mcpDraftFromConfig,
mcpWriteFailureMessage,
mcpConfigFailureMessage,
unwrapMcpIpcResult,
} from '../../renderer/features/module-hub/testing.js';

const copy = getMcpCopy('en');
Expand All @@ -41,14 +42,14 @@ test('MCP write errors retain actionable localized meaning across Electron seria
[durabilityError.message, localized.errors.writeDurabilityUnknown],
[outOfSync, localized.errors.writeOutOfSync],
]) {
assert.equal(mcpWriteFailureMessage(message, localized), expected);
assert.equal(mcpConfigFailureMessage(message, localized), expected);
assert.equal(
mcpWriteFailureMessage(new Error(`Error invoking remote method 'mcp:remove': Error: ${message}`), localized),
mcpConfigFailureMessage(new Error(`Error invoking remote method 'mcp:remove': Error: ${message}`), localized),
expected,
);
}
assert.equal(mcpWriteFailureMessage(new Error('unrelated private details'), localized), undefined);
assert.equal(mcpWriteFailureMessage(undefined, localized), undefined);
assert.equal(mcpConfigFailureMessage(new Error('unrelated private details'), localized), undefined);
assert.equal(mcpConfigFailureMessage(undefined, localized), undefined);
}
});

Expand Down Expand Up @@ -212,3 +213,27 @@ test('an untouched environment reads back unchanged, whatever its values hold',
assert.ok(isMcpStdioConfig(saved));
assert.deepEqual(saved.env, env);
});


test('corrupt MCP localization uses typed data, not English error text or custom IPC Error fields', () => {
const path = '/profile/mcp.json';
const result = structuredClone({ kind: 'invalid-mcp-config-file', path: '/profile/\u0001mcp.json' });
for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) {
const localized = getMcpCopy(locale);
assert.throws(() => unwrapMcpIpcResult(result), (error) => {
assert.equal(mcpConfigFailureMessage(error, localized), localized.errors.invalidConfigFile(path));
assert.equal(
mcpConfigFailureMessage(new Error('unrelated wrapper text', { cause: error }), localized),
localized.errors.invalidConfigFile(path),
);
return true;
});
const oldMessage = `MCP config at ${path} contains invalid JSON. The file was not modified. Close the app, back up and repair this file before retrying.`;
assert.equal(mcpConfigFailureMessage(new Error(oldMessage), localized), undefined);
}
const invalidImport = { status: 'invalid', reason: 'invalid-json' } as const;
assert.equal(unwrapMcpIpcResult(invalidImport), invalidImport);
const cyclic = new Error('cyclic cause');
cyclic.cause = cyclic;
assert.equal(mcpConfigFailureMessage(cyclic, copy), undefined);
});
72 changes: 72 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-preload-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import { build } from 'esbuild';
import type { MakaBridge } from '../../preload/bridge-contract.js';
import { unwrapMcpIpcResult, mcpConfigFailureMessage } from '../../renderer/features/module-hub/testing.js';
import { getMcpCopy } from '../../renderer/locales/mcp-copy.js';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';

Expand Down Expand Up @@ -50,3 +57,68 @@ test('every MCP bridge method rides the scoped Runtime Host seam', () => {
assert.match(preloadSource, new RegExp(`invokeSelectedRuntimeHost\\(host, '${channel}'`, 'u'));
}
});


test('every MCP bridge method carries typed config failures intact through the bundled preload', async () => {
const failure = { kind: 'invalid-mcp-config-file', path: '/profile/mcp.json' } as const;
const events = new EventEmitter();
const channels: string[] = [];
const owner = { hostId: 'owner', targetEpoch: 'epoch', profileId: 'local',
profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready' };
const ipcRenderer = {
on: events.on.bind(events), off: events.off.bind(events), send() {},
async invoke(channel: string, ...args: unknown[]) {
if (channel === 'app:bootstrapReady') return undefined;
if (channel === 'runtime-host:identities') {
return structuredClone([{ ...owner, epoch: owner.targetEpoch, isDefault: true }]);
}
if (channel === 'runtime-host:awaitReady') {
assert.deepEqual(JSON.parse(JSON.stringify(args[0])), { hostId: owner.hostId, targetEpoch: owner.targetEpoch });
return { ready: true };
}
assert.ok(channel.startsWith('mcp:'), channel);
assert.deepEqual(JSON.parse(JSON.stringify(args[0])), { hostId: owner.hostId, targetEpoch: owner.targetEpoch });
channels.push(channel);
return structuredClone(failure);
},
};
let bridge: MakaBridge | undefined;
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))],
bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'],
});
const require = createRequire(import.meta.url);
runInNewContext(bundle.outputFiles[0]!.text, {
require: (id: string) => id === 'electron' ? {
ipcRenderer,
contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) {
if (name === 'maka') bridge = value;
} },
} : require(id),
process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder,
crypto: globalThis.crypto,
});
assert.ok(bridge);
const mcp = bridge.mcp;
const host = { hostId: 'owner', profileId: 'local' };
const server = { command: 'unused' };
const calls = [
() => mcp.getConfig(host), () => mcp.listStatuses(host),
() => mcp.importConfig('{}', host), () => mcp.add('id', server, host),
() => mcp.update('id', server, server, host), () => mcp.setEnabled('id', true, host),
() => mcp.remove('id', host),
() => mcp.test('id', host), () => mcp.login('id', host),
() => mcp.cancelLogin('id', host), () => mcp.logout('id', host),
];
for (const call of calls) {
// Plain fulfilled data survives the context bridge; rebuilding an Error
// in preload would introduce a second lossy error-serialization boundary.
const result: unknown = structuredClone(await call());
assert.deepEqual(result, failure);
assert.throws(() => unwrapMcpIpcResult(result), (error) => {
assert.equal(mcpConfigFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.invalidConfigFile(failure.path));
return true;
});
}
assert.equal(new Set(channels).size, calls.length);
});
Loading