diff --git a/apps/desktop/e2e/proxy-password-editing.spec.ts b/apps/desktop/e2e/proxy-password-editing.spec.ts new file mode 100644 index 0000000000..57766c299d --- /dev/null +++ b/apps/desktop/e2e/proxy-password-editing.spec.ts @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createServer } from "node:http"; +import { test, expect, COMPOSER_INPUT } from "./fixtures"; + +test("proxy password drafts save once, reload safely, and authenticate offline", async ({ + window: page, +}) => { + const username = "proxy-user"; + const password = "complete-secret"; + let acceptAuthorization!: (value: string | undefined) => void; + const authorization = new Promise((resolve) => { + acceptAuthorization = resolve; + }); + const proxy = createServer((request, response) => { + acceptAuthorization(request.headers["proxy-authorization"]); + response.writeHead(200, { "content-length": "0", connection: "close" }); + response.end(); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", () => resolve()); + }); + const address = proxy.address(); + if (!address || typeof address === "string") { + throw new Error("Local proxy did not expose a TCP port"); + } + + try { + await page.getByRole("button", { name: "设置" }).click(); + await page.getByRole("button", { name: "通用", exact: true }).click(); + await page.getByRole("switch", { name: "启用代理服务器" }).click(); + await page.getByRole("textbox", { name: "服务器地址" }).fill("127.0.0.1"); + await page.getByRole("spinbutton", { name: "端口" }).fill(String(address.port)); + await page.getByRole("switch", { name: "启用代理认证" }).click(); + await page.getByRole("textbox", { name: "用户名" }).fill(username); + + const passwordInput = page.getByRole("textbox", { + name: "密码 凭据值", + exact: true, + }); + await passwordInput.pressSequentially(password); + await expect(passwordInput).toHaveValue(password); + await expect + .poll(() => + page.evaluate(async () => + (await window.maka.settings.get()).network.proxy.passwordConfigured, + ), + ) + .toBe(false); + + const eye = page.getByRole("button", { name: /显示|隐藏/ }); + await eye.click(); + await expect(passwordInput).toHaveAttribute("type", "text"); + await expect(passwordInput).toHaveValue(password); + await expect + .poll(() => + page.evaluate(async () => + (await window.maka.settings.get()).network.proxy.passwordConfigured, + ), + ) + .toBe(false); + + await passwordInput.focus(); + await page.keyboard.press("Tab"); + await expect(eye).toBeFocused(); + await expect + .poll(() => + page.evaluate(async () => + (await window.maka.settings.get()).network.proxy.passwordConfigured, + ), + ) + .toBe(false); + + await page.keyboard.press("Tab"); + await expect + .poll(() => + page.evaluate(async () => + (await window.maka.settings.get()).network.proxy.passwordConfigured, + ), + ) + .toBe(true); + + await page.reload(); + await page.waitForSelector(COMPOSER_INPUT); + await page.getByRole("button", { name: "设置" }).click(); + await page.getByRole("button", { name: "通用", exact: true }).click(); + const reloadedPassword = page.getByPlaceholder( + "密码已保存;输入新密码以替换", + ); + await expect(reloadedPassword).toHaveValue(""); + await expect(page.getByRole("button", { name: "复制" })).toHaveCount(0); + + const tested = await page.evaluate(() => + window.maka.settings.testNetworkProxy({ url: "http://example.com" }), + ); + expect(tested.ok).toBe(true); + expect(await authorization).toBe( + `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, + ); + } finally { + await new Promise((resolve) => proxy.close(() => resolve())); + } +}); diff --git a/apps/desktop/src/main/__tests__/password-input.test.ts b/apps/desktop/src/main/__tests__/password-input.test.ts new file mode 100644 index 0000000000..b0a94c2924 --- /dev/null +++ b/apps/desktop/src/main/__tests__/password-input.test.ts @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { + AstryxLocaleProvider, + LocaleProvider, + ToastProvider, +} from "@maka/ui"; +import { PasswordInput } from "../../renderer/settings/password-input.js"; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + Event: globalThis.Event, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test("mouse focus moving from the password draft to Eye does not commit and Eye reveals it", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + const show = harness.document.querySelector( + 'button[aria-label="Show"]', + ) as HTMLButtonElement; + assert.ok(input); + assert.ok(show); + + harness.focusExit(input, show); + assert.equal(harness.exits, 0); + await act(async () => show.click()); + assert.equal(input.type, "text"); + assert.equal(input.value, "complete-secret"); +}); + +test("keyboard focus stays inside through Eye and commits once when Tab leaves the group", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + const show = harness.document.querySelector( + 'button[aria-label="Show"]', + ) as HTMLButtonElement; + const outside = harness.document.querySelector("#outside") as HTMLButtonElement; + + harness.focusExit(input, show); + assert.equal(harness.exits, 0); + harness.focusExit(show, outside); + assert.equal(harness.exits, 1); +}); + +test("proxy password can hide Copy while ordinary password inputs keep it by default", async () => { + const harness = await renderPasswordInputs(); + const copyButtons = harness.document.querySelectorAll( + 'button[aria-label="Copy"]', + ); + + assert.equal(copyButtons.length, 1); +}); + +async function renderPasswordInputs(): Promise<{ + document: Document; + readonly exits: number; + focusExit(from: Element, to: Element): void; +}> { + const { document, window } = parseHTML( + '
', + ); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + Event: window.Event, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector("#root"); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + let exits = 0; + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: "en", + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement("div", {}, + createElement(PasswordInput, { + value: "complete-secret", + onChange() {}, + onFocusExit: () => { + exits += 1; + }, + hasCopyAction: false, + label: "Proxy password", + }), + createElement(PasswordInput, { + value: "ordinary-secret", + onChange() {}, + label: "Ordinary password", + }), + ), + }), + }), + }), + ); + }); + + const group = [...container.querySelectorAll("*")].find((element) => { + const props = reactProps(element); + return typeof props.onBlurCapture === "function"; + }); + assert.ok(group, "missing InputGroup focus boundary"); + return { + document: document as unknown as Document, + get exits() { + return exits; + }, + focusExit(_from, to) { + const handler = reactProps(group).onBlurCapture as (event: { + currentTarget: Element; + relatedTarget: Element; + }) => void; + handler({ currentTarget: group, relatedTarget: to }); + }, + }; +} + +function reactProps(element: Element): Record { + const key = Object.keys(element).find((candidate) => + candidate.startsWith("__reactProps$"), + ); + return key + ? ((element as unknown as Record)[key] as Record) + : {}; +} diff --git a/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts b/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts new file mode 100644 index 0000000000..b5ed4c4e02 --- /dev/null +++ b/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createProxyPasswordDraft, + runAfterProxyPasswordCommit, +} from "../../renderer/settings/proxy-password-draft.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +test("proxy password typing remains local until the complete draft is committed", async () => { + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + }); + + for (const value of ["s", "se", "sec", "secret"]) draft.edit(value); + assert.deepEqual(saved, []); + assert.equal(draft.value, "secret"); + + await draft.commit(); + assert.deepEqual(saved, ["secret"]); + assert.equal(draft.value, ""); +}); + +test("Enter followed by focus exit reuses one in-flight save", async () => { + const write = deferred(); + let calls = 0; + const draft = createProxyPasswordDraft(async () => { + calls += 1; + await write.promise; + }); + draft.edit("complete-secret"); + + const entered = draft.commit(); + const blurred = draft.commit(); + + assert.equal(entered, blurred); + assert.equal(calls, 1); + write.resolve(); + await entered; + assert.equal(draft.pending, false); +}); + +test("a failed save retains the complete draft for retry", async () => { + const draft = createProxyPasswordDraft(async () => { + throw new Error("save failed"); + }); + draft.edit("complete-secret"); + + await assert.rejects(draft.commit(), /save failed/); + + assert.equal(draft.value, "complete-secret"); + assert.equal(draft.pending, false); +}); + +test("an old save response never clears edits made while it was pending", async () => { + const first = deferred(); + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + if (saved.length === 1) await first.promise; + }); + draft.edit("first-secret"); + const savingFirst = draft.commit(); + draft.edit("second-secret"); + + first.resolve(); + await savingFirst; + assert.equal(draft.value, "second-secret"); + + await draft.commit(); + assert.deepEqual(saved, ["first-secret", "second-secret"]); + assert.equal(draft.value, ""); +}); + +test("a test-time commit waits for an in-flight save then commits newer edits", async () => { + const first = deferred(); + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + if (saved.length === 1) await first.promise; + }); + draft.edit("first-secret"); + void draft.commit(); + draft.edit("latest-secret"); + + const beforeTest = draft.commit(); + first.resolve(); + await beforeTest; + + assert.deepEqual(saved, ["first-secret", "latest-secret"]); + assert.equal(draft.value, ""); +}); + +test("cancel clears only work that has not entered the save lane", async () => { + const write = deferred(); + const draft = createProxyPasswordDraft(async () => write.promise); + draft.edit("queued-secret"); + const saving = draft.commit(); + draft.edit("unsubmitted-change"); + + draft.cancel(); + assert.equal(draft.value, "queued-secret"); + + write.resolve(); + await saving; + assert.equal(draft.value, ""); + + draft.edit("local-only"); + draft.cancel(); + assert.equal(draft.value, ""); +}); + +test("empty drafts are keep operations", async () => { + let calls = 0; + const draft = createProxyPasswordDraft(async () => { + calls += 1; + }); + + await draft.commit(); + + assert.equal(calls, 0); +}); + +test("proxy testing waits for the save and aborts when that save fails", async () => { + const write = deferred(); + let tests = 0; + const draft = createProxyPasswordDraft(async () => write.promise); + draft.edit("complete-secret"); + + const testing = runAfterProxyPasswordCommit(draft, async () => { + tests += 1; + return "tested"; + }); + assert.equal(tests, 0); + write.reject(new Error("save failed")); + await assert.rejects(testing, /save failed/); + assert.equal(tests, 0); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts index 322748cc47..70a3b89b25 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts @@ -19,12 +19,19 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { AppSettings } from '@maka/core/settings'; +import { + createDefaultSettings, + type RuntimeHostAppSettings, +} from '@maka/core/settings'; import type { ConnectionCatalogSnapshot, CredentialLocator, } from '@maka/core/runtime-policy'; -import { gatherRuntimeHostConfig } from '../runtime-host-config-ipc-main.js'; +import type { ConfigBundle } from '@maka/storage/config-transfer'; +import { + adaptRuntimeHostConfigImport, + gatherRuntimeHostConfig, +} from '../runtime-host-config-ipc-main.js'; const CATALOG: ConnectionCatalogSnapshot = { revision: 1, @@ -66,6 +73,7 @@ test('Runtime Host config export omits settings secrets unless credentials are s assert.deepEqual(bundle.includedData, ['settings']); assert.equal(credentialExports, 0); assert.equal('password' in settings.network.proxy, false); + assert.equal('passwordConfigured' in settings.network.proxy, false); assert.equal('token' in settings.botChat.channels.telegram, false); assert.equal('appSecret' in settings.botChat.channels.telegram, false); assert.equal('apiKey' in settings.webSearch.providers.tavily, false); @@ -105,23 +113,118 @@ test('Runtime Host config export reads selected credentials from Host authority' assert.equal(settings.botChat.channels.telegram.token, 'bot-secret'); }); -function settingsWithSecrets(): AppSettings { +test('Runtime Host config export writes an empty v1 proxy password when none is configured', async () => { + const bundle = await gatherRuntimeHostConfig( + ['settings', 'credentials'], + { + client: { + loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + exportConfigurationCredentials: async () => ({ credential: null }), + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + const settings = bundle.data.settings as Record; + assert.equal(settings.network.proxy.password, ''); + assert.equal('passwordConfigured' in settings.network.proxy, false); +}); + +test('Runtime Host config import adapts v1 proxy passwords only with credential consent', () => { + const replace = adaptRuntimeHostConfigImport( + importBundle(['settings', 'credentials'], 'complete-secret'), + ); + assert.deepEqual( + (replace.data.settings as Record).network.proxy, + { + host: '10.0.0.2', + credential: { kind: 'replace', secret: 'complete-secret' }, + }, + ); + + const remove = adaptRuntimeHostConfigImport( + importBundle(['settings', 'credentials'], ''), + ); + assert.deepEqual( + (remove.data.settings as Record).network.proxy.credential, + { kind: 'delete' }, + ); + + const keep = adaptRuntimeHostConfigImport( + importBundle(['settings', 'credentials'], undefined), + ); + assert.equal( + 'credential' in (keep.data.settings as Record).network.proxy, + false, + ); + + const ignored = adaptRuntimeHostConfigImport( + importBundle(['settings'], 'handcrafted-secret'), + ); + assert.deepEqual( + (ignored.data.settings as Record).network.proxy, + { host: '10.0.0.2' }, + ); +}); + +test('Runtime Host config import rejects a non-string v1 password during preflight', () => { + assert.throws( + () => adaptRuntimeHostConfigImport(importBundle(['settings', 'credentials'], 42)), + /password.*string/i, + ); +}); + +test('Runtime Host config import rejects conflicting authentication before apply', () => { + const bundle = importBundle( + ['connections', 'settings', 'credentials'], + 'complete-secret', + ); + (bundle.data.settings as Record).network.proxy.authEnabled = false; + + assert.throws( + () => adaptRuntimeHostConfigImport(bundle), + /authentication.*disabled/i, + ); +}); + +function settingsWithSecrets(): RuntimeHostAppSettings { + const settings = createDefaultSettings(); + settings.botChat.channels.telegram.token = 'bot-secret'; + settings.botChat.channels.telegram.appSecret = 'app-secret'; + (settings.webSearch.providers.tavily as { apiKey: string }).apiKey = + 'local-tavily-secret'; return { - theme: 'dark', - network: { proxy: { host: '127.0.0.1', password: 'local-proxy-secret' } }, - botChat: { - channels: { - telegram: { - chatId: '42', - token: 'bot-secret', - appSecret: 'app-secret', - }, + ...settings, + network: { + proxy: { + ...settings.network.proxy, + passwordConfigured: true, }, }, - webSearch: { - providers: { tavily: { apiKey: 'local-tavily-secret' } }, + }; +} + +function importBundle( + includedData: ConfigBundle['includedData'], + password: unknown, +): ConfigBundle { + const proxy: Record = { + host: '10.0.0.2', + passwordConfigured: true, + credential: { kind: 'replace', secret: 'injected-operation' }, + }; + if (password !== undefined) proxy.password = password; + return { + schemaVersion: 1, + exportedAt: '', + appVersion: '', + includedData, + data: { + settings: { network: { proxy } }, + ...(includedData.includes('credentials') ? { credentials: [] } : {}), }, - } as unknown as AppSettings; + }; } function secretFor(locator: CredentialLocator): string | null { diff --git a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts index b11c39fc5b..6001a51afa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts @@ -19,11 +19,19 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + createDefaultSettings, + type UpdateAppSettingsInput, +} from "@maka/core/settings"; import { createDefaultRuntimePolicy, type RuntimePolicy, } from "@maka/core/runtime-policy"; -import { registerRuntimeHostSettingsIpc } from "../runtime-host-settings-ipc-main.js"; +import { + createRuntimeHostSettingsModule, + registerRuntimeHostSettingsIpc, + runRuntimeHostSettingsExclusive, +} from "../runtime-host-settings-ipc-main.js"; type TestCandidate = RuntimePolicy["networkProxy"]; @@ -95,3 +103,263 @@ test("proxy test preserves disabled authentication for a local proxy", async () assert.equal(tested.result.ok, true); assert.equal(tested.result.code, "proxy_reachable"); }); + +function createModuleFixture(options: { + configured?: boolean; + beforeSetCredential?: () => Promise; + failFirstSet?: boolean; +} = {}) { + let policy = createDefaultRuntimePolicy(); + let secret = options.configured ? "saved-secret" : undefined; + let revision = secret ? 1 : 0; + let failFirstSet = options.failFirstSet ?? false; + const events: string[] = []; + const local = createDefaultSettings(); + + const client = { + async queryRuntimePolicy() { + return { revision: 1, policy }; + }, + async updateRuntimePolicy( + createMutation: (value: RuntimePolicy) => { + kind: string; + value: RuntimePolicy["networkProxy"]; + }, + ) { + const mutation = createMutation(policy); + if (mutation.kind === "set_network_proxy") { + policy = { ...policy, networkProxy: mutation.value }; + } + return { revision: 2, policy }; + }, + async queryCredential(locator: { scope: string }) { + if (locator.scope !== "network_proxy" || secret === undefined) return null; + return { + locator: { scope: "network_proxy", kind: "password" }, + configured: true, + credentialId: "proxy-credential", + revision, + updatedAt: 1, + }; + }, + async setCredential(input: { secret: string }) { + events.push(`set:${input.secret}`); + await options.beforeSetCredential?.(); + if (failFirstSet) { + failFirstSet = false; + throw new Error("credential write failed"); + } + secret = input.secret; + revision += 1; + return { kind: "committed", snapshot: { revision, entries: [] } }; + }, + async deleteCredential() { + events.push("delete"); + secret = undefined; + revision += 1; + return { kind: "committed", snapshot: { revision, entries: [] } }; + }, + async testNetworkProxy() { + events.push("test"); + return { ok: true, latencyMs: 1, status: 200 }; + }, + }; + + const module = createRuntimeHostSettingsModule({ + client: client as never, + settingsStore: { + async get() { + return local; + }, + async update(_patch: UpdateAppSettingsInput) { + return local; + }, + } as never, + async applyClientSettings() {}, + }); + + return { + module, + events, + policy: () => policy, + secret: () => secret, + }; +} + +test("runtime settings project credential status without a password value", async () => { + const fixture = createModuleFixture({ configured: true }); + + const settings = await fixture.module.get(); + + assert.equal(settings.network.proxy.passwordConfigured, true); + assert.equal("password" in settings.network.proxy, false); +}); + +test("spread-back derived and legacy password fields never enter Runtime policy", async () => { + const fixture = createModuleFixture({ configured: true }); + + await fixture.module.update({ + network: { + proxy: { + host: "10.0.0.2", + passwordConfigured: true, + password: "legacy-secret", + } as never, + }, + }); + + assert.equal(fixture.policy().networkProxy.host, "10.0.0.2"); + assert.equal("passwordConfigured" in fixture.policy().networkProxy, false); + assert.equal("password" in fixture.policy().networkProxy, false); +}); + +test("proxy credential operations validate before any write", async () => { + for (const proxy of [ + { + credential: { kind: "replace", secret: "" }, + }, + { + authEnabled: false, + credential: { kind: "replace", secret: "new-secret" }, + }, + ] satisfies Array["proxy"]>) { + const fixture = createModuleFixture({ configured: true }); + await assert.rejects( + fixture.module.update({ network: { proxy } }), + /credential|password|authentication/i, + ); + assert.deepEqual(fixture.events, []); + assert.equal(fixture.secret(), "saved-secret"); + } +}); + +test("disabling the proxy keeps credentials while disabling authentication removes them", async () => { + const fixture = createModuleFixture({ configured: true }); + + await fixture.module.update({ network: { proxy: { enabled: false } } }); + assert.equal(fixture.secret(), "saved-secret"); + + await fixture.module.update({ + network: { proxy: { authEnabled: false } }, + }); + assert.equal(fixture.secret(), undefined); + assert.deepEqual(fixture.events, ["delete"]); +}); + +test("keep, replace, and explicit delete preserve the derived credential contract", async () => { + const fixture = createModuleFixture({ configured: true }); + + const kept = await fixture.module.update({ + network: { proxy: { username: "updated-user" } }, + }); + assert.equal(fixture.secret(), "saved-secret"); + assert.equal(kept.network.proxy.passwordConfigured, true); + + const replaced = await fixture.module.update({ + network: { + proxy: { + authEnabled: true, + credential: { kind: "replace", secret: "replacement" }, + }, + }, + }); + assert.equal(fixture.secret(), "replacement"); + assert.equal(replaced.network.proxy.passwordConfigured, true); + + const deleted = await fixture.module.update({ + network: { + proxy: { authEnabled: true, credential: { kind: "delete" } }, + }, + }); + assert.equal(fixture.policy().networkProxy.authEnabled, true); + assert.equal(fixture.secret(), undefined); + assert.equal(deleted.network.proxy.passwordConfigured, false); +}); + +test("a later authentication disable waits for an in-flight replacement and wins", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const fixture = createModuleFixture({ beforeSetCredential: () => blocked }); + + const replace = fixture.module.update({ + network: { + proxy: { + authEnabled: true, + credential: { kind: "replace", secret: "complete-secret" }, + }, + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + const disable = fixture.module.update({ + network: { proxy: { authEnabled: false } }, + }); + + assert.deepEqual(fixture.events, ["set:complete-secret"]); + release(); + await Promise.all([replace, disable]); + assert.deepEqual(fixture.events, ["set:complete-secret", "delete"]); + assert.equal(fixture.secret(), undefined); +}); + +test("proxy tests wait for the lane and a failed operation does not poison it", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const fixture = createModuleFixture({ + beforeSetCredential: () => blocked, + failFirstSet: true, + }); + const replace = fixture.module.update({ + network: { + proxy: { credential: { kind: "replace", secret: "complete-secret" } }, + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + const testResult = fixture.module.testNetworkProxy({}); + + assert.deepEqual(fixture.events, ["set:complete-secret"]); + release(); + await assert.rejects(replace, /failed/); + assert.equal((await testResult).ok, true); + assert.deepEqual(fixture.events, ["set:complete-secret", "test"]); +}); + +test("compound config operations share the lane without re-entering it", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const fixture = createModuleFixture({ beforeSetCredential: () => blocked }); + const replace = fixture.module.update({ + network: { + proxy: { credential: { kind: "replace", secret: "complete-secret" } }, + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + const config = runRuntimeHostSettingsExclusive( + fixture.module, + async (settings) => { + fixture.events.push("config:start"); + await settings.update({ network: { proxy: { username: "imported-user" } } }); + const projected = await settings.get(); + fixture.events.push("config:end"); + return projected; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(fixture.events, ["set:complete-secret"]); + + release(); + await replace; + const projected = await config; + assert.equal(projected.network.proxy.username, "imported-user"); + assert.deepEqual(fixture.events, [ + "set:complete-secret", + "config:start", + "config:end", + ]); +}); diff --git a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts index 12aca10f85..5cb745ba31 100644 --- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts @@ -29,21 +29,18 @@ import { } from "../settings-ipc-helpers.js"; describe("settings IPC helpers", () => { - test("masks sensitive network and bot fields before returning settings to renderer", () => { + test("masks sensitive bot fields before returning settings to renderer", () => { const settings = createDefaultSettings(); - settings.network.proxy.password = "proxy-secret"; settings.botChat.channels.telegram.token = "telegram-secret"; settings.botChat.channels.feishu.appSecret = "feishu-secret"; const masked = maskAppSettings(settings); - assert.equal(masked.network.proxy.password, SENSITIVE_PLACEHOLDER); assert.equal(masked.botChat.channels.telegram.token, SENSITIVE_PLACEHOLDER); assert.equal( masked.botChat.channels.feishu.appSecret, SENSITIVE_PLACEHOLDER, ); - assert.equal(settings.network.proxy.password, "proxy-secret"); }); test("keeps empty sensitive fields empty instead of showing a placeholder", () => { @@ -51,7 +48,6 @@ describe("settings IPC helpers", () => { const masked = maskAppSettings(settings); - assert.equal(masked.network.proxy.password, ""); assert.equal(masked.botChat.channels.telegram.token, ""); }); @@ -72,16 +68,13 @@ describe("settings IPC helpers", () => { test("reveals sensitive fields only when the current patch explicitly changes them", () => { const settings = createDefaultSettings(); - settings.network.proxy.password = "new-proxy-secret"; settings.botChat.channels.telegram.token = "new-bot-token"; settings.botChat.channels.feishu.appSecret = "stored-feishu-secret"; const masked = maskAppSettings(settings, { - network: { proxy: { password: "new-proxy-secret" } }, botChat: { channels: { telegram: { token: "new-bot-token" } } }, }); - assert.equal(masked.network.proxy.password, "new-proxy-secret"); assert.equal(masked.botChat.channels.telegram.token, "new-bot-token"); assert.equal( masked.botChat.channels.feishu.appSecret, @@ -107,15 +100,11 @@ describe("settings IPC helpers", () => { test("preserves placeholder values as stored secrets before persisting patches", () => { const current = createDefaultSettings(); - current.network.proxy.password = "stored-proxy-secret"; current.botChat.channels.telegram.token = "stored-bot-token"; current.botChat.channels.feishu.appSecret = "stored-feishu-secret"; const patch = preserveSensitivePlaceholders( { - network: { - proxy: { password: SENSITIVE_PLACEHOLDER, host: "10.0.0.2" }, - }, botChat: { channels: { telegram: { token: SENSITIVE_PLACEHOLDER, enabled: true }, @@ -126,8 +115,6 @@ describe("settings IPC helpers", () => { current, ); - assert.equal(patch.network?.proxy?.password, "stored-proxy-secret"); - assert.equal(patch.network?.proxy?.host, "10.0.0.2"); assert.equal(patch.botChat?.channels?.telegram?.token, "stored-bot-token"); assert.equal(patch.botChat?.channels?.telegram?.enabled, true); assert.equal( diff --git a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts index 8deb6fff8c..22f8bb6dec 100644 --- a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts +++ b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts @@ -19,7 +19,10 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { createDefaultSettings } from '@maka/core/settings'; +import { + createDefaultSettings, + type RuntimeHostAppSettings, +} from '@maka/core/settings'; import { beginSettingsResourceLoad, completeSettingsResourceLoad, @@ -40,6 +43,19 @@ import { createSettingsRequestAuthority } from '../../renderer/settings/settings const LOCAL_KEY = 'local:host-local-1'; const REMOTE_KEY = 'remote:host-remote-1'; +function runtimeHostSettings(): RuntimeHostAppSettings { + const settings = createDefaultSettings(); + return { + ...settings, + network: { + proxy: { + ...settings.network.proxy, + passwordConfigured: false, + }, + }, + }; +} + function catalog(entries: DesktopRuntimeHostProfileSnapshot['entries']): DesktopRuntimeHostProfileSnapshot { return { defaultProfileId: 'local', @@ -188,9 +204,9 @@ describe('Settings snapshot cache', () => { it('isolates settings and connections by selected Runtime Host key', () => { const cache = createSettingsSnapshotCache(); - const localSettings = createDefaultSettings(); + const localSettings = runtimeHostSettings(); const remoteSettings = { - ...createDefaultSettings(), + ...runtimeHostSettings(), personalization: { ...createDefaultSettings().personalization, displayName: 'Remote Host', @@ -210,7 +226,7 @@ describe('Settings snapshot cache', () => { it('prunes snapshots when a profile reconnects with a new host id', () => { const cache = createSettingsSnapshotCache(); - cache.commitRuntimeHostSettingsRead(LOCAL_KEY, createDefaultSettings()); + cache.commitRuntimeHostSettingsRead(LOCAL_KEY, runtimeHostSettings()); cache.commitRuntimeHostConnectionsRead(LOCAL_KEY, { connections: [], defaultSlug: null, @@ -232,7 +248,7 @@ describe('Settings snapshot cache', () => { it('stores settings and connection reads independently', () => { const cache = createSettingsSnapshotCache(); - const settings = createDefaultSettings(); + const settings = runtimeHostSettings(); cache.commitRuntimeHostSettingsRead(LOCAL_KEY, settings); assert.equal(cache.readRuntimeHostSettings(LOCAL_KEY), settings); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32ac4b5c9e..ce3535e6b2 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -181,9 +181,8 @@ import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog. import { createRuntimeHostDefaultRecovery } from "./runtime-host-default-recovery.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { - loadRuntimeHostSettings, + createRuntimeHostSettingsModule, registerRuntimeHostSettingsIpc, - updateRuntimeHostSettings, } from "./runtime-host-settings-ipc-main.js"; import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; @@ -1132,29 +1131,29 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: target.kind === "local", }); - const settingsIpcDeps = { - ipcMain: scopedIpc, + const runtimeHostSettings = createRuntimeHostSettingsModule({ client, settingsStore, applyClientSettings: async (settings) => { await clientSettingsEffects.apply(settings, true); }, - } satisfies Parameters[0]; - registerRuntimeHostSettingsIpc(settingsIpcDeps); + }); + registerRuntimeHostSettingsIpc({ + ipcMain: scopedIpc, + module: runtimeHostSettings, + }); registerRuntimeHostConfigIpc({ ipcMain: scopedIpc, client, mainWindowController, appVersion: app.getVersion(), - getSettings: () => loadRuntimeHostSettings(settingsIpcDeps), - updateSettings: (patch) => - updateRuntimeHostSettings(settingsIpcDeps, patch), + settingsModule: runtimeHostSettings, emitConnectionsChanged: emitTargetConnectionListChanged, }); registerRuntimeHostPermissionsIpc({ ipcMain: scopedIpc, client, - getSettings: () => loadRuntimeHostSettings(settingsIpcDeps), + getSettings: () => runtimeHostSettings.get(), listConnections: async () => projectHostConnections(await client.loadConnectionCatalog()), botRegistry, @@ -1193,7 +1192,7 @@ function registerHostClientIpc( return selectedDesktopWorkspaceTarget(target); }, getDefaultPermissionMode: () => - resolveDefaultPermissionMode(() => loadRuntimeHostSettings(settingsIpcDeps)), + resolveDefaultPermissionMode(() => runtimeHostSettings.get()), openPath: (path) => shell.openPath(path), allowLocalPaths: target.kind === "local", }); diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 1a8c32149d..73146fbb0f 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -43,12 +43,17 @@ import { import { stripSettingsSecretsForExport, } from './settings-ipc-helpers.js'; +import { + runRuntimeHostSettingsExclusive, + type RuntimeHostSettingsModule, +} from './runtime-host-settings-ipc-main.js'; import { buildConfigBundle, isConfigCategory, parseConfigBundle, serializeConfigBundle, type ConfigCategory, + type ConfigBundle, type ConfigData, type ConnectionConflictStrategy, } from '@maka/storage/config-transfer'; @@ -58,11 +63,21 @@ interface RuntimeHostConfigIpcDeps { readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; readonly appVersion: string; + readonly settingsModule: RuntimeHostSettingsModule; + readonly emitConnectionsChanged: () => void; +} + +interface RuntimeHostConfigGatherDeps { + readonly client: DesktopRuntimeHostClient; + readonly appVersion: string; readonly getSettings: () => Promise; +} + +interface RuntimeHostConfigTransferDeps { + readonly client: DesktopRuntimeHostClient; readonly updateSettings: ( patch: UpdateAppSettingsInput, ) => Promise; - readonly emitConnectionsChanged: () => void; } export function registerRuntimeHostConfigIpc( @@ -75,7 +90,6 @@ export function registerRuntimeHostConfigIpc( if (categories.length === 0) { return { ok: false as const, reason: 'no_categories' as const }; } - const bundle = await gatherRuntimeHostConfig(categories, deps); const today = new Date().toISOString().slice(0, 10); const result = await deps.mainWindowController.showSaveDialog({ title: '导出 Maka 配置', @@ -85,6 +99,15 @@ export function registerRuntimeHostConfigIpc( if (result.canceled || !result.filePath) { return { ok: false as const, reason: 'canceled' as const }; } + const bundle = await runRuntimeHostSettingsExclusive( + deps.settingsModule, + (settings) => + gatherRuntimeHostConfig(categories, { + client: deps.client, + appVersion: deps.appVersion, + getSettings: settings.get, + }), + ); await writeFile(result.filePath, serializeConfigBundle(bundle), 'utf8'); return { ok: true as const, @@ -114,10 +137,26 @@ export function registerRuntimeHostConfigIpc( message: parsed.message, }; } - const imported = await applyConfigImport( - parsed.bundle, - sanitizeStrategy(input?.strategy), - runtimeHostTransferDeps(deps), + let importBundle: ConfigBundle; + try { + importBundle = adaptRuntimeHostConfigImport(parsed.bundle); + } catch (error) { + return { + ok: false as const, + reason: 'malformed' as const, + message: error instanceof Error ? error.message : 'Invalid settings payload.', + }; + } + const imported = await runRuntimeHostSettingsExclusive( + deps.settingsModule, + (settings) => + applyConfigImport( + importBundle, + sanitizeStrategy(input?.strategy), + runtimeHostTransferDeps( + { client: deps.client, updateSettings: settings.update }, + ), + ), ); deps.emitConnectionsChanged(); return { @@ -131,7 +170,7 @@ export function registerRuntimeHostConfigIpc( export async function gatherRuntimeHostConfig( categories: readonly ConfigCategory[], - deps: RuntimeHostConfigIpcDeps, + deps: RuntimeHostConfigGatherDeps, ) { const selected = new Set(categories); const data: ConfigData = {}; @@ -183,7 +222,7 @@ async function exportConfigurationCredentials( } function runtimeHostTransferDeps( - deps: RuntimeHostConfigIpcDeps, + deps: RuntimeHostConfigTransferDeps, ): ConfigTransferDeps { return { connectionStore: { @@ -339,16 +378,22 @@ function connectionCredentials( function restoreHostSettingsSecrets( settings: AppSettings, secrets: ReadonlyMap, -): AppSettings { +): Record { const proxy = secrets.get(locatorKey({ scope: 'network_proxy', kind: 'password' })) ?? ''; const webSearch = secrets.get( locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), ) ?? ''; + const { + passwordConfigured: _passwordConfigured, + ...proxySettings + } = settings.network.proxy as typeof settings.network.proxy & { + passwordConfigured?: boolean; + }; return { ...settings, network: { - proxy: { ...settings.network.proxy, password: proxy }, + proxy: { ...proxySettings, password: proxy }, }, webSearch: { ...settings.webSearch, @@ -362,6 +407,75 @@ function restoreHostSettingsSecrets( }; } +/** Convert schema-v1 wire secrets into the write-only Runtime Host contract. */ +export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle { + const settings = bundle.data.settings; + if (!isRecord(settings)) return bundle; + const network = settings.network; + if (!isRecord(network) || !isRecord(network.proxy)) return bundle; + + const wireProxy = network.proxy; + const passwordPresent = Object.prototype.hasOwnProperty.call( + wireProxy, + 'password', + ); + const password = wireProxy.password; + const includesCredentials = bundle.includedData.includes('credentials'); + if ( + includesCredentials && + passwordPresent && + typeof password !== 'string' + ) { + throw new Error('Proxy password in imported settings must be a string.'); + } + if ( + includesCredentials && + typeof password === 'string' && + password.length > 0 && + wireProxy.authEnabled === false + ) { + throw new Error( + 'Cannot import a proxy password while proxy authentication is disabled.', + ); + } + + const { + password: _password, + passwordConfigured: _passwordConfigured, + credential: _credential, + ...ordinaryProxy + } = wireProxy; + const proxy = { + ...ordinaryProxy, + ...(includesCredentials && passwordPresent + ? { + credential: + (password as string).length === 0 + ? ({ kind: 'delete' } as const) + : ({ kind: 'replace', secret: password as string } as const), + } + : {}), + }; + + return { + ...bundle, + data: { + ...bundle.data, + settings: { + ...settings, + network: { + ...network, + proxy, + }, + }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function connectionCredentialLocator( connection: ConnectionCatalogEntry, ): Extract | null { diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index f7b6065941..bbdb2e8183 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -19,6 +19,7 @@ import type { AppSettings, + RuntimeHostAppSettings, SettingsTestResult, UpdateAppSettingsInput, UpdateAppSettingsResult, @@ -76,78 +77,109 @@ export interface RuntimeHostSettingsIpcDeps { readonly applyClientSettings: (settings: AppSettings) => Promise; } +export type RuntimeHostSettingsModuleDeps = Omit< + RuntimeHostSettingsIpcDeps, + "ipcMain" +>; + +export interface RuntimeHostSettingsModule { + get(): Promise; + update(patch: UpdateAppSettingsInput): Promise; + testNetworkProxy(input?: TestProxyInput): Promise; +} + +export interface RuntimeHostSettingsExclusiveAccess { + get(): Promise; + update(patch: UpdateAppSettingsInput): Promise; +} + +type RuntimeHostSettingsExclusiveRunner = ( + operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise, +) => Promise; + +const exclusiveRunners = new WeakMap< + RuntimeHostSettingsModule, + RuntimeHostSettingsExclusiveRunner +>(); + +type RuntimeHostSettingsIpcRegistrationDeps = + | RuntimeHostSettingsIpcDeps + | { + readonly ipcMain: ReconnectableReadIpcMain; + readonly module: RuntimeHostSettingsModule; + }; + +export function createRuntimeHostSettingsModule( + deps: RuntimeHostSettingsModuleDeps, +): RuntimeHostSettingsModule { + let lane: Promise = Promise.resolve(); + + function enqueue(operation: () => Promise): Promise { + const result = lane.then(operation, operation); + lane = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + const module: RuntimeHostSettingsModule = { + get: () => enqueue(() => loadRuntimeHostSettingsWithoutLane(deps)), + update: (patch) => + enqueue(() => updateRuntimeHostSettingsWithoutLane(deps, patch)), + testNetworkProxy: (input = {}) => + enqueue(() => testNetworkProxyWithoutLane(deps.client, input)), + }; + exclusiveRunners.set(module, (operation) => + enqueue(() => + operation({ + get: () => loadRuntimeHostSettingsWithoutLane(deps), + update: (patch) => updateRuntimeHostSettingsWithoutLane(deps, patch), + }), + ), + ); + return module; +} + +/** + * Runs a compound Settings adapter operation in this Runtime Host's lane. + * The supplied accessors deliberately bypass re-entry into the public queue. + */ +export function runRuntimeHostSettingsExclusive( + module: RuntimeHostSettingsModule, + operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise, +): Promise { + const run = exclusiveRunners.get(module); + if (!run) { + throw new Error('Runtime Host Settings module does not own an exclusive lane'); + } + return run(operation); +} + export function registerRuntimeHostSettingsIpc( - deps: RuntimeHostSettingsIpcDeps, + deps: RuntimeHostSettingsIpcRegistrationDeps, ): void { + const module = + "module" in deps ? deps.module : createRuntimeHostSettingsModule(deps); handleReconnectableRead(deps.ipcMain, "settings:get", async () => - maskAppSettings(await loadRuntimeHostSettings(deps)), + maskAppSettings(await module.get()), ); deps.ipcMain.handle( "settings:testNetworkProxy", - async (_event, input: TestProxyInput = {}) => { - const current = (await deps.client.queryRuntimePolicy()).policy - .networkProxy; - const candidate = input.proxy - ? toRuntimeHostProxyPolicy(input.proxy, current.autoBypassDomains) - : undefined; - const password = credentialOverride(input.proxy?.password); - const result = await deps.client.testNetworkProxy({ - ...(candidate ? { networkProxy: candidate } : {}), - ...(password ? { password } : {}), - ...(input.url ? { url: input.url } : {}), - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - const tested = candidate ?? current; - if (!result.ok) { - const failure = proxyTestFailure(result); - return { - ok: false, - ...failure, - latencyMs: result.latencyMs, - details: { status: result.status }, - } satisfies SettingsTestResult; - } - return { - ok: true, - code: "proxy_reachable", - message: `The proxy ${tested.protocol}://${tested.host}:${tested.port} is reachable.`, - latencyMs: result.latencyMs, - details: { - endpoint: `${tested.protocol}://${tested.host}:${tested.port}`, - status: result.status, - ip: result.ip, - countryCode: result.countryCode, - countryFlag: result.countryFlag, - bypassList: tested.bypassList, - }, - } satisfies SettingsTestResult; - }, + async (_event, input: TestProxyInput = {}) => module.testNetworkProxy(input), ); deps.ipcMain.handle( "settings:update", async ( _event, patch: UpdateAppSettingsInput, - ): Promise => { - const settings = await updateRuntimeHostSettings(deps, patch); + ): Promise> => { + const settings = await module.update(patch); return buildSettingsUpdateResult(settings, patch); }, ); } -export async function updateRuntimeHostSettings( - deps: RuntimeHostSettingsIpcDeps, - patch: UpdateAppSettingsInput, -): Promise { - await applyHostPatch(deps.client, patch); - const clientPatch = clientOwnedSettingsPatch(patch); - const local = hasSettingsPatch(clientPatch) - ? await deps.settingsStore.update(clientPatch) - : await deps.settingsStore.get(); - await deps.applyClientSettings(local); - return loadRuntimeHostSettings(deps); -} - function toRuntimeHostProxyPolicy( proxy: TestProxySettings, autoBypassDomains: readonly string[], @@ -171,9 +203,50 @@ function credentialOverride(value: string | undefined): string | undefined { return !value || value === SENSITIVE_PLACEHOLDER ? undefined : value; } -export async function loadRuntimeHostSettings( - deps: RuntimeHostSettingsIpcDeps, -): Promise { +async function testNetworkProxyWithoutLane( + client: RuntimeHostSettingsClient, + input: TestProxyInput, +): Promise { + const current = (await client.queryRuntimePolicy()).policy.networkProxy; + const candidate = input.proxy + ? toRuntimeHostProxyPolicy(input.proxy, current.autoBypassDomains) + : undefined; + const password = credentialOverride(input.proxy?.password); + const result = await client.testNetworkProxy({ + ...(candidate ? { networkProxy: candidate } : {}), + ...(password ? { password } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + const tested = candidate ?? current; + if (!result.ok) { + const failure = proxyTestFailure(result); + return { + ok: false, + ...failure, + latencyMs: result.latencyMs, + details: { status: result.status }, + }; + } + return { + ok: true, + code: "proxy_reachable", + message: `The proxy ${tested.protocol}://${tested.host}:${tested.port} is reachable.`, + latencyMs: result.latencyMs, + details: { + endpoint: `${tested.protocol}://${tested.host}:${tested.port}`, + status: result.status, + ip: result.ip, + countryCode: result.countryCode, + countryFlag: result.countryFlag, + bypassList: tested.bypassList, + }, + }; +} + +async function loadRuntimeHostSettingsWithoutLane( + deps: RuntimeHostSettingsModuleDeps, +): Promise { const [local, runtimePolicy, proxyCredential, webSearchCredential] = await Promise.all([ deps.settingsStore.get(), @@ -189,7 +262,7 @@ export async function loadRuntimeHostSettings( ...policy.networkProxy, bypassList: [...policy.networkProxy.bypassList], autoBypassDomains: [...policy.networkProxy.autoBypassDomains], - password: proxyCredential?.configured ? SENSITIVE_PLACEHOLDER : "", + passwordConfigured: proxyCredential?.configured === true, }, }, personalization: { @@ -212,6 +285,20 @@ export async function loadRuntimeHostSettings( }; } +async function updateRuntimeHostSettingsWithoutLane( + deps: RuntimeHostSettingsModuleDeps, + patch: UpdateAppSettingsInput, +): Promise { + validateProxyPatch(patch.network?.proxy); + await applyHostPatchWithoutLane(deps.client, patch); + const clientPatch = clientOwnedSettingsPatch(patch); + const local = hasSettingsPatch(clientPatch) + ? await deps.settingsStore.update(clientPatch) + : await deps.settingsStore.get(); + await deps.applyClientSettings(local); + return loadRuntimeHostSettingsWithoutLane(deps); +} + function projectWebSearchCredential( local: AppSettings, credential: CredentialStatus | null, @@ -234,7 +321,7 @@ function projectWebSearchCredential( }; } -async function applyHostPatch( +async function applyHostPatchWithoutLane( client: RuntimeHostSettingsClient, patch: UpdateAppSettingsInput, ): Promise { @@ -242,18 +329,12 @@ async function applyHostPatch( const proxy = patch.network.proxy; await client.updateRuntimePolicy((policy) => ({ kind: "set_network_proxy", - value: { ...policy.networkProxy, ...withoutSecret(proxy) }, + value: { ...policy.networkProxy, ...withoutCredential(proxy) }, })); - if (proxy.authEnabled === false) + if (proxy.authEnabled === false || proxy.credential?.kind === "delete") await deleteCredential(client, PROXY_CREDENTIAL); - else if ( - proxy.password !== undefined && - proxy.password !== SENSITIVE_PLACEHOLDER - ) { - if (proxy.password.length === 0) - await deleteCredential(client, PROXY_CREDENTIAL); - else await setCredential(client, PROXY_CREDENTIAL, proxy.password); - } + else if (proxy.credential?.kind === "replace") + await setCredential(client, PROXY_CREDENTIAL, proxy.credential.secret); } if ( patch.personalization?.displayName !== undefined || @@ -391,9 +472,38 @@ async function deleteCredential( throw new Error("Credential kept changing while Desktop removed it"); } -function withoutSecret( +function withoutCredential( patch: NonNullable["proxy"]>, ): Partial { - const { password: _password, ...value } = patch; + const { + credential: _credential, + password: _legacyPassword, + passwordConfigured: _derivedStatus, + ...value + } = patch as typeof patch & { + password?: unknown; + passwordConfigured?: unknown; + }; return value; } + +function validateProxyPatch( + proxy: NonNullable["proxy"] | undefined, +): void { + const operation = proxy?.credential; + if (!operation) return; + if (operation.kind === "replace") { + if (typeof operation.secret !== "string" || operation.secret.length === 0) { + throw new Error("Proxy credential replacement requires a non-empty password"); + } + if (proxy.authEnabled === false) { + throw new Error( + "Cannot replace the proxy credential while authentication is disabled", + ); + } + return; + } + if (operation.kind !== "delete") { + throw new Error("Unsupported proxy credential operation"); + } +} diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index 2404d1463f..9443c932da 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -19,6 +19,7 @@ import type { AppSettings, + RuntimeHostAppSettings, SettingsTestResult, SettingsTestResultCode, UpdateAppSettingsInput, @@ -93,17 +94,6 @@ export function preserveSensitivePlaceholders( return { ...patch, - ...(patch.network?.proxy?.password === SENSITIVE_PLACEHOLDER - ? { - network: { - ...patch.network, - proxy: { - ...patch.network.proxy, - password: current.network.proxy.password, - }, - }, - } - : {}), ...(botChannels ? { botChat: { @@ -115,21 +105,20 @@ export function preserveSensitivePlaceholders( }; } +export function maskAppSettings( + settings: RuntimeHostAppSettings, + revealPatch?: UpdateAppSettingsInput, +): RuntimeHostAppSettings; +export function maskAppSettings( + settings: AppSettings, + revealPatch?: UpdateAppSettingsInput, +): AppSettings; export function maskAppSettings( settings: AppSettings, revealPatch: UpdateAppSettingsInput = {}, ): AppSettings { return { ...settings, - network: { - ...settings.network, - proxy: { - ...settings.network.proxy, - password: shouldReveal(revealPatch.network?.proxy?.password) - ? settings.network.proxy.password - : (maskSensitive(settings.network.proxy.password) ?? ""), - }, - }, botChat: { ...settings.botChat, channels: Object.fromEntries( @@ -183,6 +172,7 @@ export function stripSettingsSecretsForExport( ): Record { const proxy = { ...settings.network.proxy } as Record; delete proxy.password; + delete proxy.passwordConfigured; const channels: Record = {}; for (const [provider, channel] of Object.entries(settings.botChat.channels)) { @@ -209,6 +199,14 @@ export function stripSettingsSecretsForExport( }; } +export function buildSettingsUpdateResult( + settings: RuntimeHostAppSettings, + patch: UpdateAppSettingsInput, +): UpdateAppSettingsResult; +export function buildSettingsUpdateResult( + settings: AppSettings, + patch: UpdateAppSettingsInput, +): UpdateAppSettingsResult; export function buildSettingsUpdateResult( settings: AppSettings, patch: UpdateAppSettingsInput, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..4b538304e6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -30,6 +30,7 @@ import type { AppIcon, AppIconChoice, AppSettings, + RuntimeHostAppSettings, ChatDefaultsSettings, SettingsTestResult, UpdateAppSettingsInput, @@ -1016,9 +1017,9 @@ export interface MakaBridge { }; settings: { getClient(): Promise; - get(host?: DesktopRuntimeHostRef): Promise; + get(host?: DesktopRuntimeHostRef): Promise; updateClient(patch: UpdateAppSettingsInput): Promise; - update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise; + update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise>; subscribeClientChanged(handler: () => void): () => void; subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 656472bf20..125a1591e2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -95,6 +95,7 @@ import type { AppIcon, AppIconChoice, AppSettings, + RuntimeHostAppSettings, SettingsTestResult, UpdateAppSettingsInput, UpdateAppSettingsResult, @@ -2603,13 +2604,13 @@ const makaBridge = { getClient(): Promise { return ipcRenderer.invoke('settings:client:get'); }, - get(host?: DesktopRuntimeHostRef): Promise { + get(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'settings:get'); }, updateClient(patch: UpdateAppSettingsInput): Promise { return ipcRenderer.invoke('settings:client:update', patch); }, - update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise { + update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise> { return invokeSelectedRuntimeHost(host, 'settings:update', patch); }, subscribeClientChanged(handler: () => void): () => void { diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index f3f716a473..438ca68946 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -181,6 +181,7 @@ export type SettingsPreferencesCopy = { enableProxyAuth: string; username: string; password: string; + passwordSavedPlaceholder: string; bypassList: string; bypassHelp: string; autoBypass(count: number): string; @@ -302,6 +303,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败', shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自动模式保持 Windows 的 PowerShell 优先规则;Git Bash 是仅对当前 Runtime Host 生效的显式覆盖。', shellAuto: '自动(推荐)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 可执行文件', shellExecutableHelp: '填写 Runtime Host 所在 Windows 机器上 bash.exe 的绝对路径。也支持该机器上的旧版 System32 WSL Bash;保存时会验证 GNU Bash。', saveShell: '保存 shell 设置', savingShell: '正在保存…', shellSaved: '已保存', saveShellFailed: '保存 shell 设置失败', shellExecutableRejected: '当前 Runtime Host 无法把该路径作为 GNU Bash 运行。请检查 Host 是否为 Windows、路径是否存在,并确认文件名为 bash.exe。', proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', + passwordSavedPlaceholder: '密码已保存;输入新密码以替换', }, about: { loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyDiagnostics: '复制诊断信息', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', @@ -353,6 +355,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { general: { incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'See existing work in one entry and conservatively route new input to ordinary tasks.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', + passwordSavedPlaceholder: 'Password saved; enter a new password to replace it', }, about: { loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and security', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay in the local workspace.', 'Model keys stay in a local credential file; subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task.', 'Messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host logs. The report is written only to the clipboard and is never uploaded automatically.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 40e48cb63d..0096c7018c 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -31,6 +31,7 @@ import type { ChatDefaultPermissionMode, ShellPreference, NetworkProxySettings, + RuntimeHostNetworkProxySettings, UpdateAppSettingsResult, } from '@maka/core/settings'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -60,6 +61,8 @@ import { getConversationCopy } from '@maka/ui'; import { settingsActionErrorMessage } from "./settings-error-copy"; import { useActionGuard, useKeyedActionGuard } from "./use-action-guard"; import { useOptimisticSettingsDraft } from "./use-optimistic-settings-draft"; +import { useProxyPasswordDraft } from "./use-proxy-password-draft.js"; +import { runAfterProxyPasswordCommit } from "./proxy-password-draft.js"; import { getSettingsPreferencesCopy } from "../locales/settings-preferences-copy.js"; import { settingsTestResultMessage } from "../locales/settings-test-result-copy.js"; import { getShellCopy } from "../locales/shell-copy.js"; @@ -746,29 +749,35 @@ function NetworkProxySection(props: { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getSettingsPreferencesCopy(locale).general; - const persistedProxy = props.settings.network.proxy; + const persistedProxy = props.settings.network + .proxy as RuntimeHostNetworkProxySettings; const [testing, setTesting] = useState(false); const proxyTestGuard = useActionGuard<"test">(); const toast = useToast(); + function reportNetworkSaveError(error: unknown): void { + toast.error( + copy.saveNetworkFailed, + settingsActionErrorMessage(error, locale), + undefined, + { profileId: host.profileId }, + ); + } const { draft: proxyDraft, draftRef: proxyDraftRef, mountedRef: networkPageMountedRef, update, - } = useOptimisticSettingsDraft( + } = useOptimisticSettingsDraft( persistedProxy, (patch) => props .onUpdate({ network: { proxy: patch } }) - .then((result) => result.settings.network.proxy), + .then( + (result) => + result.settings.network.proxy as RuntimeHostNetworkProxySettings, + ), { - onError: (error) => - toast.error( - copy.saveNetworkFailed, - settingsActionErrorMessage(error, locale), - undefined, - { profileId: host.profileId }, - ), + onError: reportNetworkSaveError, }, ); @@ -777,12 +786,27 @@ function NetworkProxySection(props: { return update(patch); } + const passwordDraft = useProxyPasswordDraft(async (secret) => { + try { + await props.onUpdate({ + network: { + proxy: { credential: { kind: "replace", secret } }, + }, + }); + } catch (error) { + reportNetworkSaveError(error); + throw error; + } + }); + async function testProxy() { if (!props.isInteractive) return; if (!proxyTestGuard.begin("test")) return; setTesting(true); try { - const result = await props.testNetworkProxy(toProxyTestInput(proxyDraftRef.current)); + const result = await runAfterProxyPasswordCommit(passwordDraft, () => + props.testNetworkProxy(toProxyTestInput(proxyDraftRef.current)), + ); const latency = result.latencyMs !== undefined ? ` · ${result.latencyMs} ms` : ""; const message = settingsTestResultMessage(result, locale); @@ -875,7 +899,16 @@ function NetworkProxySection(props: { isLabelHidden value={proxyDraft.authEnabled} isDisabled={!props.isInteractive} - onChange={(authEnabled) => void updateProxy({ authEnabled })} + onChange={(authEnabled) => { + if (authEnabled) { + void updateProxy({ authEnabled }); + return; + } + passwordDraft.cancel(); + void updateProxy({ authEnabled }).then((saved) => { + if (saved) passwordDraft.cancel(); + }); + }} /> } /> @@ -890,8 +923,19 @@ function NetworkProxySection(props: { isDisabled={!props.isInteractive} /> void updateProxy({ password: next })} + value={passwordDraft.value} + onChange={passwordDraft.edit} + onFocusExit={() => void passwordDraft.commit().catch(() => {})} + onEnter={() => void passwordDraft.commit().catch(() => {})} + onKeyDown={(event) => { + if (event.key === "Escape") passwordDraft.cancel(); + }} + hasCopyAction={false} + placeholder={ + proxyDraft.passwordConfigured + ? copy.passwordSavedPlaceholder + : undefined + } label={copy.password} isDisabled={!props.isInteractive} /> @@ -947,8 +991,6 @@ function toProxyTestInput(proxy: NetworkProxySettings): TestProxyInput { proxy.authEnabled && proxy.username.trim() ? proxy.username.trim() : undefined, - password: - proxy.authEnabled && proxy.password ? proxy.password : undefined, bypassList: proxy.bypassList, }, }; diff --git a/apps/desktop/src/renderer/settings/password-input.tsx b/apps/desktop/src/renderer/settings/password-input.tsx index 278db46b1b..82a1d9857a 100644 --- a/apps/desktop/src/renderer/settings/password-input.tsx +++ b/apps/desktop/src/renderer/settings/password-input.tsx @@ -17,7 +17,12 @@ * under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { + useEffect, + useRef, + useState, + type KeyboardEvent, +} from 'react'; import { ICON_SIZE, Check, Copy, Eye, EyeOff } from '@maka/ui/icons'; import { IconButton, @@ -29,7 +34,7 @@ import { useToast, useUiLocale, } from '@maka/ui'; -import { useActionGuard } from './use-action-guard'; +import { useActionGuard } from './use-action-guard.js'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; /** @@ -58,7 +63,10 @@ export function PasswordInput(props: { isRequired?: boolean; isOptional?: boolean; isDisabled?: boolean; - onBlur?(): void; + onFocusExit?(): void; + onEnter?(): void; + onKeyDown?(event: KeyboardEvent): void; + hasCopyAction?: boolean; hasAutoFocus?: boolean; }) { const copy = getSettingsPreferencesCopy(useUiLocale()).password; @@ -118,12 +126,23 @@ export function PasswordInput(props: { isRequired={props.isRequired} isOptional={props.isOptional} status={props.status} + onBlurCapture={(event) => { + const destination = event.relatedTarget; + if ( + destination && + event.currentTarget.contains(destination as Node) + ) { + return; + } + props.onFocusExit?.(); + }} > props.onChange(value)} - onBlur={props.onBlur} + onEnter={props.onEnter} + onKeyDown={props.onKeyDown} placeholder={props.placeholder} label={copy.value} isLabelHidden @@ -134,7 +153,7 @@ export function PasswordInput(props: { /> {/* InputGroupText: the sanctioned addon segment — bare IconButtons break the group's caps. */} - {props.value && !props.isDisabled && ( + {(props.hasCopyAction ?? true) && props.value && !props.isDisabled && ( ; + cancel(): void; + subscribe(listener: () => void): () => void; +} + +export async function runAfterProxyPasswordCommit( + draft: Pick, + operation: () => Promise, +): Promise { + await draft.commit(); + return operation(); +} + +interface ActiveSave { + readonly secret: string; + readonly promise: Promise; +} + +/** Owns the write-only password draft and hides save de-duplication from UI callers. */ +export function createProxyPasswordDraft( + save: (secret: string) => Promise, +): ProxyPasswordDraft { + let value = ""; + let active: ActiveSave | undefined; + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) listener(); + } + + function commit(): Promise { + if (active) { + if (!value || value === active.secret) return active.promise; + return active.promise.then(() => commit()); + } + if (!value) return Promise.resolve(); + + const secret = value; + let write: Promise; + try { + write = save(secret); + } catch (error) { + write = Promise.reject(error); + } + const promise = write + .then(() => { + if (value === secret) value = ""; + }) + .finally(() => { + if (active?.promise === promise) active = undefined; + notify(); + }); + active = { secret, promise }; + notify(); + return promise; + } + + return { + get value() { + return value; + }, + get pending() { + return active !== undefined; + }, + edit(next) { + if (next === value) return; + value = next; + notify(); + }, + commit, + cancel() { + const next = active?.secret ?? ""; + if (next === value) return; + value = next; + notify(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index cdfb1d7bf9..d25bb069e7 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { AppSettings } from '@maka/core/settings'; +import type { AppSettings, RuntimeHostAppSettings } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileSnapshot, @@ -36,8 +36,8 @@ export interface SettingsSnapshotCache { readRuntimeHostCatalog(): DesktopRuntimeHostProfileSnapshot | undefined; commitRuntimeHostCatalogRead(snapshot: DesktopRuntimeHostProfileSnapshot): void; - readRuntimeHostSettings(key: string): AppSettings | undefined; - commitRuntimeHostSettingsRead(key: string, snapshot: AppSettings): void; + readRuntimeHostSettings(key: string): RuntimeHostAppSettings | undefined; + commitRuntimeHostSettingsRead(key: string, snapshot: RuntimeHostAppSettings): void; readRuntimeHostConnections(key: string): RuntimeHostConnectionsSnapshot | undefined; commitRuntimeHostConnectionsRead( @@ -58,7 +58,7 @@ export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string { export function createSettingsSnapshotCache(): SettingsSnapshotCache { let client: AppSettings | undefined; let runtimeHostCatalog: DesktopRuntimeHostProfileSnapshot | undefined; - const runtimeHostSettings = new Map(); + const runtimeHostSettings = new Map(); const runtimeHostConnections = new Map(); return { diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 6ac34ebe35..839e131985 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -41,6 +41,7 @@ import { import { ICON_SIZE, ArrowLeft } from '@maka/ui/icons'; import type { AppSettings, + RuntimeHostAppSettings, ChatDefaultPermissionMode, SettingsSection, ThemePalette, @@ -266,7 +267,7 @@ export function SettingsSurface(props: { initialClientSettings ?? defaultSettings, ); const [runtimeHostSettings, setRuntimeHostSettings] = useState< - SettingsResourceState + SettingsResourceState >(() => createSettingsResourceState( initialRuntimeHostKey, initialRuntimeHostKey @@ -586,7 +587,10 @@ export function SettingsSurface(props: { return result; } if (acceptedHostUpdate && hostKey) { - setRuntimeHostSettings(completeSettingsResourceLoad(hostKey, result.settings)); + setRuntimeHostSettings(completeSettingsResourceLoad( + hostKey, + result.settings as RuntimeHostAppSettings, + )); void reloadRuntimeHostSettings(host); } else if ( clientTicket !== undefined && diff --git a/apps/desktop/src/renderer/settings/use-proxy-password-draft.ts b/apps/desktop/src/renderer/settings/use-proxy-password-draft.ts new file mode 100644 index 0000000000..9cb66cb857 --- /dev/null +++ b/apps/desktop/src/renderer/settings/use-proxy-password-draft.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useReducer, useRef } from "react"; +import { + createProxyPasswordDraft, + type ProxyPasswordDraft, +} from "./proxy-password-draft.js"; + +export function useProxyPasswordDraft( + save: (secret: string) => Promise, +): ProxyPasswordDraft { + const saveRef = useRef(save); + saveRef.current = save; + const draftRef = useRef(null); + draftRef.current ??= createProxyPasswordDraft((secret) => + saveRef.current(secret), + ); + const draft = draftRef.current; + const [, rerender] = useReducer((value: number) => value + 1, 0); + useEffect(() => draft.subscribe(rerender), [draft]); + return draft; +} diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 6fed9d5b6f..465b88a86f 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -23,6 +23,7 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { ToastProvider, useToast } from '@maka/ui'; import type { AppSettings, + RuntimeHostAppSettings, SettingsSection, ThemePalette, ThemePreference, @@ -671,11 +672,29 @@ const unavailableRuntimeHostProfiles: DesktopRuntimeHostProfileSnapshot = { const STORY_RUNTIME_HOST_KEY = 'local:storybook-local-host'; +function storyRuntimeSettings( + settings: AppSettings = createDefaultSettings(), + passwordConfigured = false, +): RuntimeHostAppSettings { + return { + ...settings, + network: { + proxy: { + ...settings.network.proxy, + passwordConfigured, + }, + }, + }; +} + function seedGeneralSnapshotCache(cache: SettingsSnapshotCache): void { const settings = createDefaultSettings(); cache.commitClientRead(settings); cache.commitRuntimeHostCatalogRead(runtimeHostProfiles); - cache.commitRuntimeHostSettingsRead(STORY_RUNTIME_HOST_KEY, settings); + cache.commitRuntimeHostSettingsRead( + STORY_RUNTIME_HOST_KEY, + storyRuntimeSettings(settings), + ); cache.commitRuntimeHostConnectionsRead(STORY_RUNTIME_HOST_KEY, { connections, defaultSlug: 'zai-live', @@ -696,7 +715,7 @@ function seedGeneralTwoHostSnapshotCache(cache: SettingsSnapshotCache): void { } let storyClientSettings = createDefaultSettings(); -let storyRuntimeHostSettings = createDefaultSettings(); +let storyRuntimeHostSettings = storyRuntimeSettings(); const makaBridge = { runtimeHostProfiles: { @@ -742,7 +761,16 @@ const makaBridge = { return { settings: storyClientSettings }; }, update: async (patch: Parameters[0]): Promise => { - storyRuntimeHostSettings = mergeSettings(storyRuntimeHostSettings, patch); + const merged = mergeSettings(storyRuntimeHostSettings, patch); + storyRuntimeHostSettings = storyRuntimeSettings( + merged, + patch.network?.proxy?.credential?.kind === 'replace' + ? true + : patch.network?.proxy?.credential?.kind === 'delete' || + patch.network?.proxy?.authEnabled === false + ? false + : storyRuntimeHostSettings.network.proxy.passwordConfigured, + ); return { settings: storyRuntimeHostSettings }; }, subscribeClientChanged: () => () => undefined, diff --git a/packages/core/src/__tests__/settings.test.ts b/packages/core/src/__tests__/settings.test.ts index 2cc040cd63..3f11f2b5ed 100644 --- a/packages/core/src/__tests__/settings.test.ts +++ b/packages/core/src/__tests__/settings.test.ts @@ -183,3 +183,38 @@ test('WorkHub stays opt-in and malformed persisted values fail closed', () => { enabled: true, }); }); + +test('proxy credentials never enter persisted settings', () => { + const defaults = createDefaultSettings(); + expect('password' in defaults.network.proxy).toBe(false); + expect('passwordConfigured' in defaults.network.proxy).toBe(false); + + const merged = mergeSettings(defaults, { + network: { + proxy: { + host: '10.0.0.2', + credential: { kind: 'replace', secret: 'complete-secret' }, + password: 'legacy-secret', + passwordConfigured: true, + }, + }, + } as never); + + expect(merged.network.proxy.host).toBe('10.0.0.2'); + expect('credential' in merged.network.proxy).toBe(false); + expect('password' in merged.network.proxy).toBe(false); + expect('passwordConfigured' in merged.network.proxy).toBe(false); + + const normalized = normalizeSettings({ + network: { + proxy: { + password: 'legacy-secret', + passwordConfigured: true, + credential: { kind: 'delete' }, + }, + }, + }); + expect('credential' in normalized.network.proxy).toBe(false); + expect('password' in normalized.network.proxy).toBe(false); + expect('passwordConfigured' in normalized.network.proxy).toBe(false); +}); diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index e3467dad00..6e83b90ecf 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -98,11 +98,24 @@ export interface NetworkProxySettings { port: number; authEnabled: boolean; username: string; - password: string; bypassList: string[]; autoBypassDomains: string[]; } +export type NetworkProxyCredentialOperation = + | { kind: 'replace'; secret: string } + | { kind: 'delete' }; + +/** A write-only proxy patch. Credential operations are never persisted. */ +export type NetworkProxySettingsPatch = Partial & { + credential?: NetworkProxyCredentialOperation; +}; + +/** Runtime Host read projection; the saved secret itself never crosses IPC. */ +export interface RuntimeHostNetworkProxySettings extends NetworkProxySettings { + readonly passwordConfigured: boolean; +} + /** * Persisted application network settings. Runtime proxy execution uses the * separate contract in `settings/network-settings.ts`. @@ -378,6 +391,12 @@ export interface AppSettings { subagents: SubagentSettings; } +export interface RuntimeHostAppSettings extends Omit { + network: { + proxy: RuntimeHostNetworkProxySettings; + }; +} + export interface UsageRequestLog { id: string; ts: number; @@ -464,7 +483,7 @@ export type SettingsTestResultCode = export type UpdateAppSettingsInput = Partial<{ network: Partial<{ - proxy: Partial; + proxy: NetworkProxySettingsPatch; }>; botChat: BotChatSettingsPatch; usage: Partial; @@ -492,8 +511,8 @@ export interface UpdateAppSettingsWarnings { personalization?: PersonalizationSettingsWarning[]; } -export interface UpdateAppSettingsResult { - settings: AppSettings; +export interface UpdateAppSettingsResult { + settings: TSettings; warnings?: UpdateAppSettingsWarnings; } @@ -517,7 +536,6 @@ export function createDefaultSettings(): AppSettings { port: 7890, authEnabled: false, username: '', - password: '', bypassList: ['metaso.cn', 'baidu.com'], autoBypassDomains: DEFAULT_PROXY_BYPASS_DOMAINS, }, @@ -572,6 +590,15 @@ export function createDefaultSettings(): AppSettings { } export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInput): AppSettings { + const { + credential: _credential, + password: _legacyPassword, + passwordConfigured: _derivedStatus, + ...proxyPatch + } = (patch.network?.proxy ?? {}) as NetworkProxySettingsPatch & { + password?: unknown; + passwordConfigured?: unknown; + }; return { ...current, network: { @@ -579,7 +606,7 @@ export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInpu ...(patch.network ?? {}), proxy: { ...current.network.proxy, - ...(patch.network?.proxy ?? {}), + ...proxyPatch, }, }, botChat: mergeBotChatSettings(current.botChat, patch.botChat), diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts index 390006ed3b..3ba64c0683 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -189,8 +189,8 @@ class FileSettingsStore implements SettingsStore { if (!Number.isInteger(proxy.port) || proxy.port <= 0 || proxy.port > 65535) { return { ok: false, message: '代理端口必须在 1-65535 之间' }; } - if (proxy.authEnabled && (!proxy.username.trim() || !proxy.password)) { - return { ok: false, message: '启用代理认证后需要用户名和密码' }; + if (proxy.authEnabled && !proxy.username.trim()) { + return { ok: false, message: '启用代理认证后需要用户名' }; } return { ok: true,