From ef683b91796d4bb1ef77ae165d083a915f711c88 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:24:27 +0800 Subject: [PATCH 01/20] fix(desktop): prevent proxy password mask corruption Generated-by: OpenAI Codex --- .../e2e/proxy-password-editing.spec.ts | 121 ++++++++ .../src/main/__tests__/password-input.test.ts | 165 +++++++++++ .../__tests__/proxy-password-draft.test.ts | 166 +++++++++++ .../runtime-host-config-ipc-main.test.ts | 133 ++++++++- .../runtime-host-settings-ipc-main.test.ts | 270 +++++++++++++++++- .../__tests__/settings-ipc-helpers.test.ts | 15 +- .../__tests__/settings-resource-state.test.ts | 26 +- apps/desktop/src/main/runtime-host-boot.ts | 21 +- .../src/main/runtime-host-config-ipc-main.ts | 134 ++++++++- .../main/runtime-host-settings-ipc-main.ts | 254 +++++++++++----- apps/desktop/src/main/settings-ipc-helpers.ts | 38 ++- apps/desktop/src/preload/bridge-contract.d.ts | 5 +- apps/desktop/src/preload/preload.ts | 5 +- .../locales/settings-preferences-copy.ts | 3 + .../settings/general-settings-page.tsx | 74 +++-- .../src/renderer/settings/password-input.tsx | 30 +- .../renderer/settings/proxy-password-draft.ts | 105 +++++++ .../settings/settings-snapshot-cache.ts | 8 +- .../renderer/settings/settings-surface.tsx | 8 +- .../settings/use-proxy-password-draft.ts | 39 +++ .../settings/settings-pages.stories.tsx | 34 ++- packages/core/src/__tests__/settings.test.ts | 35 +++ packages/core/src/settings.ts | 39 ++- packages/storage/src/settings-store.ts | 4 +- 24 files changed, 1542 insertions(+), 190 deletions(-) create mode 100644 apps/desktop/e2e/proxy-password-editing.spec.ts create mode 100644 apps/desktop/src/main/__tests__/password-input.test.ts create mode 100644 apps/desktop/src/main/__tests__/proxy-password-draft.test.ts create mode 100644 apps/desktop/src/renderer/settings/proxy-password-draft.ts create mode 100644 apps/desktop/src/renderer/settings/use-proxy-password-draft.ts 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 5476d7e02f..94010df573 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -190,9 +190,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"; @@ -1187,29 +1186,29 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: !usesHostWorkspace, }); - 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, @@ -1248,7 +1247,7 @@ function registerHostClientIpc( return selectedDesktopWorkspaceTarget(target); }, getDefaultPermissionMode: () => - resolveDefaultPermissionMode(() => loadRuntimeHostSettings(settingsIpcDeps)), + resolveDefaultPermissionMode(() => runtimeHostSettings.get()), openPath: (path) => shell.openPath(path), allowLocalPaths: !usesHostWorkspace, }); 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 3bfc79a6ce..9b2e22fd28 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -31,6 +31,7 @@ import type { AppIconChoice, AppIconTarget, AppSettings, + RuntimeHostAppSettings, ChatDefaultsSettings, SettingsTestResult, UpdateAppSettingsInput, @@ -1229,9 +1230,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 155ac853c6..d4675cc042 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -108,6 +108,7 @@ import type { AppIconChoice, AppIconTarget, AppSettings, + RuntimeHostAppSettings, SettingsTestResult, UpdateAppSettingsInput, UpdateAppSettingsResult, @@ -2753,13 +2754,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 770e7e9953..3c742388d7 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -207,6 +207,7 @@ export type SettingsPreferencesCopy = { enableProxyAuth: string; username: string; password: string; + passwordSavedPlaceholder: string; bypassList: string; bypassHelp: string; autoBypass(count: number): string; @@ -335,6 +336,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: '报告问题', @@ -388,6 +390,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: 'WorkHub is not available yet. This toggle is for development testing and does not enable a usable feature.', 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 5662117c54..6a9cd14ebd 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 7aa0ac37fa..82e1681205 100644 --- a/apps/desktop/src/renderer/settings/password-input.tsx +++ b/apps/desktop/src/renderer/settings/password-input.tsx @@ -17,7 +17,13 @@ * under the License. */ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { + useEffect, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from 'react'; import { ICON_SIZE, Check, Copy, Eye, EyeOff } from '@maka/ui/icons'; import { IconButton, @@ -29,7 +35,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'; /** @@ -63,7 +69,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; @@ -125,12 +134,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 @@ -141,7 +161,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 bd7c59a4b8..dbd4cdbbaf 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 { IdentifiedLlmConnection } 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 8addb78be9..779c3afca4 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 @@ -605,7 +606,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 b1dbd489c4..2b5fc456bf 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, @@ -701,11 +702,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', @@ -726,7 +745,7 @@ function seedGeneralTwoHostSnapshotCache(cache: SettingsSnapshotCache): void { } let storyClientSettings = createDefaultSettings(); -let storyRuntimeHostSettings = createDefaultSettings(); +let storyRuntimeHostSettings = storyRuntimeSettings(); const makaBridge = { runtimeHostProfiles: { @@ -772,7 +791,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 7dde0968c7..484d869116 100644 --- a/packages/core/src/__tests__/settings.test.ts +++ b/packages/core/src/__tests__/settings.test.ts @@ -319,3 +319,38 @@ describe('app icon on upgrade', () => { expect(migrated.appearance.appIconDark).toBe(undefined); }); }); + +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 001bde9aa6..f066f9664c 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -99,11 +99,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`. @@ -552,6 +565,12 @@ export interface AppSettings { subagents: SubagentSettings; } +export interface RuntimeHostAppSettings extends Omit { + network: { + proxy: RuntimeHostNetworkProxySettings; + }; +} + export interface UsageRequestLog { id: string; ts: number; @@ -653,7 +672,7 @@ export type SettingsTestResultCode = export type UpdateAppSettingsInput = Partial<{ network: Partial<{ - proxy: Partial; + proxy: NetworkProxySettingsPatch; }>; botChat: BotChatSettingsPatch; usage: Partial; @@ -681,8 +700,8 @@ export interface UpdateAppSettingsWarnings { personalization?: PersonalizationSettingsWarning[]; } -export interface UpdateAppSettingsResult { - settings: AppSettings; +export interface UpdateAppSettingsResult { + settings: TSettings; warnings?: UpdateAppSettingsWarnings; } @@ -706,7 +725,6 @@ export function createDefaultSettings(): AppSettings { port: 7890, authEnabled: false, username: '', - password: '', bypassList: ['metaso.cn', 'baidu.com'], autoBypassDomains: DEFAULT_PROXY_BYPASS_DOMAINS, }, @@ -763,6 +781,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: { @@ -770,7 +797,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 20f369374d..43250ae069 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -203,8 +203,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, From 97e11edf6e953488d9f99c326f17f37e4b078784 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:15:24 +0800 Subject: [PATCH 02/20] fix(runtime-host): make proxy updates recoverable Generated-by: OpenAI Codex --- .../runtime-host-settings-ipc-main.test.ts | 91 +++++++++- apps/desktop/src/main/runtime-host-client.ts | 6 + .../main/runtime-host-settings-ipc-main.ts | 48 ++++- packages/core/src/runtime-policy.ts | 30 ++++ .../core/src/runtime-policy/policy-codec.ts | 56 ++++++ .../src/__tests__/protocol.test.ts | 64 +++++++ .../runtime-policy-coordinator.test.ts | 73 ++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 1 + .../src/protocol/runtime-policy.ts | 55 ++++++ .../src/server/runtime-policy-coordinator.ts | 28 +++ .../__tests__/runtime-policy-stores.test.ts | 125 +++++++++++++ packages/storage/src/runtime-policy-stores.ts | 1 + .../storage/src/runtime-policy/coordinator.ts | 167 ++++++++++++++++++ .../storage/src/runtime-policy/document-io.ts | 2 +- .../network-proxy-transaction.ts | 96 ++++++++++ .../storage/src/runtime-policy/operations.ts | 3 + 17 files changed, 835 insertions(+), 15 deletions(-) create mode 100644 packages/storage/src/runtime-policy/network-proxy-transaction.ts 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 6001a51afa..4ac2a48b75 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 @@ -26,6 +26,7 @@ import { import { createDefaultRuntimePolicy, type RuntimePolicy, + type UpdateNetworkProxyInput, } from "@maka/core/runtime-policy"; import { createRuntimeHostSettingsModule, @@ -110,6 +111,13 @@ function createModuleFixture(options: { failFirstSet?: boolean; } = {}) { let policy = createDefaultRuntimePolicy(); + if (options.configured) { + policy = { + ...policy, + networkProxy: { ...policy.networkProxy, authEnabled: true }, + }; + } + let policyRevision = 1; let secret = options.configured ? "saved-secret" : undefined; let revision = secret ? 1 : 0; let failFirstSet = options.failFirstSet ?? false; @@ -118,7 +126,7 @@ function createModuleFixture(options: { const client = { async queryRuntimePolicy() { - return { revision: 1, policy }; + return { revision: policyRevision, policy }; }, async updateRuntimePolicy( createMutation: (value: RuntimePolicy) => { @@ -130,7 +138,53 @@ function createModuleFixture(options: { if (mutation.kind === "set_network_proxy") { policy = { ...policy, networkProxy: mutation.value }; } - return { revision: 2, policy }; + policyRevision += 1; + return { revision: policyRevision, policy }; + }, + async updateNetworkProxy(input: UpdateNetworkProxyInput) { + if (input.expectedPolicyRevision !== policyRevision) { + return { + kind: "revision_conflict" as const, + expectedRevision: input.expectedPolicyRevision, + actualRevision: policyRevision, + }; + } + if (input.credential.kind === "replace") { + events.push(`set:${input.credential.secret}`); + await options.beforeSetCredential?.(); + if (failFirstSet) { + failFirstSet = false; + throw new Error("credential write failed"); + } + secret = input.credential.secret; + revision += 1; + } else if (input.credential.kind === "delete") { + events.push("delete"); + secret = undefined; + revision += 1; + } + policy = { ...policy, networkProxy: input.networkProxy }; + policyRevision += 1; + return { + kind: "committed" as const, + revision: policyRevision, + credentialStatus: + secret === undefined + ? { + locator: { scope: "network_proxy" as const, kind: "password" as const }, + configured: false as const, + credentialId: null, + revision: null, + updatedAt: null, + } + : { + locator: { scope: "network_proxy" as const, kind: "password" as const }, + configured: true as const, + credentialId: "proxy-credential", + revision, + updatedAt: 1, + }, + }; }, async queryCredential(locator: { scope: string }) { if (locator.scope !== "network_proxy" || secret === undefined) return null; @@ -314,7 +368,10 @@ test("proxy tests wait for the lane and a failed operation does not poison it", }); const replace = fixture.module.update({ network: { - proxy: { credential: { kind: "replace", secret: "complete-secret" } }, + proxy: { + authEnabled: true, + credential: { kind: "replace", secret: "complete-secret" }, + }, }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -327,6 +384,29 @@ test("proxy tests wait for the lane and a failed operation does not poison it", assert.deepEqual(fixture.events, ["set:complete-secret", "test"]); }); +test("a failed credential replacement does not commit proxy policy fields", async () => { + const fixture = createModuleFixture({ configured: true, failFirstSet: true }); + const before = structuredClone(fixture.policy().networkProxy); + + await assert.rejects( + fixture.module.update({ + network: { + proxy: { + enabled: true, + host: "replacement.proxy.internal", + authEnabled: true, + username: "replacement-user", + credential: { kind: "replace", secret: "replacement-secret" }, + }, + }, + }), + /failed/, + ); + + assert.deepEqual(fixture.policy().networkProxy, before); + assert.equal(fixture.secret(), "saved-secret"); +}); + test("compound config operations share the lane without re-entering it", async () => { let release!: () => void; const blocked = new Promise((resolve) => { @@ -335,7 +415,10 @@ test("compound config operations share the lane without re-entering it", async ( const fixture = createModuleFixture({ beforeSetCredential: () => blocked }); const replace = fixture.module.update({ network: { - proxy: { credential: { kind: "replace", secret: "complete-secret" } }, + proxy: { + authEnabled: true, + credential: { kind: "replace", secret: "complete-secret" }, + }, }, }); await new Promise((resolve) => setImmediate(resolve)); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 81e9775ec0..9418ad1aa6 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -334,6 +334,12 @@ export class DesktopRuntimeHostClient { throw revisionConflict("Runtime Policy update", "workspace"); } + updateNetworkProxy( + input: OperationInput<"runtime.policy.network-proxy.update">, + ): Promise> { + return this.request("runtime.policy.network-proxy.update", input); + } + queryMemory(input: MemoryQueryInput): Promise { return this.request("memory.query", input); } 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 bbdb2e8183..55d80b175b 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -57,6 +57,7 @@ type RuntimeHostSettingsClient = Pick< | "queryRuntimePolicy" | "setCredential" | "testNetworkProxy" + | "updateNetworkProxy" | "updateRuntimePolicy" >; @@ -326,15 +327,7 @@ async function applyHostPatchWithoutLane( patch: UpdateAppSettingsInput, ): Promise { if (patch.network?.proxy) { - const proxy = patch.network.proxy; - await client.updateRuntimePolicy((policy) => ({ - kind: "set_network_proxy", - value: { ...policy.networkProxy, ...withoutCredential(proxy) }, - })); - if (proxy.authEnabled === false || proxy.credential?.kind === "delete") - await deleteCredential(client, PROXY_CREDENTIAL); - else if (proxy.credential?.kind === "replace") - await setCredential(client, PROXY_CREDENTIAL, proxy.credential.secret); + await updateNetworkProxy(client, patch.network.proxy); } if ( patch.personalization?.displayName !== undefined || @@ -407,6 +400,43 @@ async function applyHostPatchWithoutLane( } } +async function updateNetworkProxy( + client: RuntimeHostSettingsClient, + patch: NonNullable["proxy"]>, +): Promise { + const [policy, credential] = await Promise.all([ + client.queryRuntimePolicy(), + client.queryCredential(PROXY_CREDENTIAL), + ]); + const networkProxy = { + ...policy.policy.networkProxy, + ...withoutCredential(patch), + }; + const operation = + patch.credential?.kind === "replace" + ? patch.credential + : !networkProxy.authEnabled || patch.credential?.kind === "delete" + ? ({ kind: "delete" } as const) + : ({ kind: "keep" } as const); + const result = await client.updateNetworkProxy({ + expectedPolicyRevision: policy.revision, + expectedCredential: credential?.configured + ? { + locator: credential.locator, + credentialId: credential.credentialId, + revision: credential.revision, + } + : null, + networkProxy, + credential: operation, + }); + if (result.kind === "committed") return; + if (result.kind === "revision_conflict") { + throw new Error("Runtime Host proxy policy changed while Desktop updated it"); + } + throw new Error("Runtime Host proxy credential changed while Desktop updated it"); +} + async function mergePolicy< K extends "memory" | "workspaceInstructions" | "privacy" | "chatDefaults" | "shell", >( diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index a6da3087af..39059f86c1 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -44,6 +44,7 @@ export { export { decodeCanonicalRuntimePolicy, decodeRuntimePolicyV2, + normalizeNetworkProxyUpdate, normalizeRuntimePolicyMutation, } from './runtime-policy/policy-codec.js'; export { @@ -184,6 +185,35 @@ export type MutateRuntimePolicyResult = | { readonly kind: 'committed'; readonly snapshot: RuntimePolicySnapshot } | RevisionConflict; +export type NetworkProxyCredentialUpdate = + | { readonly kind: 'keep' } + | { readonly kind: 'replace'; readonly secret: string } + | { readonly kind: 'delete' }; + +/** + * One optimistic basis for the Host-owned proxy policy and credential pair. + * The Runtime Host validates both generations before publishing either side. + */ +export interface UpdateNetworkProxyInput { + readonly expectedPolicyRevision: Revision; + readonly expectedCredential: CredentialVersionBasis | null; + readonly networkProxy: RuntimePolicy['networkProxy']; + readonly credential: NetworkProxyCredentialUpdate; +} + +export type UpdateNetworkProxyResult = + | { + readonly kind: 'committed'; + readonly snapshot: RuntimePolicySnapshot; + readonly credentialStatus: CredentialStatus; + } + | RevisionConflict + | { + readonly kind: 'credential_stale'; + readonly expected: CredentialVersionBasis | null; + readonly actual: CredentialVersionBasis | null; + }; + export function createDefaultRuntimePolicy(): RuntimePolicy { return { networkProxy: { diff --git a/packages/core/src/runtime-policy/policy-codec.ts b/packages/core/src/runtime-policy/policy-codec.ts index 442f19eb57..b0f5a0997d 100644 --- a/packages/core/src/runtime-policy/policy-codec.ts +++ b/packages/core/src/runtime-policy/policy-codec.ts @@ -23,9 +23,15 @@ import { normalizeSubagentSettings } from '../subagent-settings.js'; import type { AgentRuntimeSettingsPatch, MutateRuntimePolicyInput, + NetworkProxyCredentialUpdate, RuntimePolicy, RuntimePolicyMutation, + UpdateNetworkProxyInput, } from '../runtime-policy.js'; +import { + decodeCredentialVersionBasis, + normalizeCredentialSecret, +} from './credential-vault-codec.js'; import { WEB_SEARCH_PROVIDERS } from '../web-search.js'; import { assertCanonicalValue, @@ -74,6 +80,56 @@ export function normalizeRuntimePolicyMutation(value: unknown): MutateRuntimePol }; } +export function normalizeNetworkProxyUpdate(value: unknown): UpdateNetworkProxyInput { + const input = exactRecord(value, 'network proxy update', [ + 'expectedPolicyRevision', + 'expectedCredential', + 'networkProxy', + 'credential', + ]); + const expectedCredential = + input.expectedCredential === null + ? null + : decodeCredentialVersionBasis(input.expectedCredential); + if (expectedCredential !== null && expectedCredential.locator.scope !== 'network_proxy') { + throw domainError('network proxy update requires a network proxy credential basis'); + } + const credential = normalizeNetworkProxyCredentialUpdate(input.credential); + const networkProxy = normalizeNetworkProxy(input.networkProxy); + if (!networkProxy.authEnabled && credential.kind !== 'delete') { + throw domainError('disabled proxy authentication requires credential deletion'); + } + return { + expectedPolicyRevision: revisionValue( + input.expectedPolicyRevision, + 'network proxy expected policy revision', + ), + expectedCredential, + networkProxy, + credential, + }; +} + +function normalizeNetworkProxyCredentialUpdate(value: unknown): NetworkProxyCredentialUpdate { + const base = exactRecord(value, 'network proxy credential update', ['kind', 'secret'], ['kind']); + switch (base.kind) { + case 'keep': + case 'delete': { + exactRecord(value, `network proxy credential ${base.kind}`, ['kind']); + return { kind: base.kind }; + } + case 'replace': { + const replacement = exactRecord(value, 'network proxy credential replacement', [ + 'kind', + 'secret', + ]); + return { kind: 'replace', secret: normalizeCredentialSecret(replacement.secret) }; + } + default: + throw domainError(`network proxy credential update '${String(base.kind)}' is unknown`); + } +} + function normalizeRuntimePolicy(value: unknown): RuntimePolicy { const policy = exactRecord(value, 'runtime policy', [ 'networkProxy', diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 42e611cd6b..145372103b 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -231,6 +231,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); }); + test('publishes a new compatibility epoch for compound proxy updates', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 61); + }); + test('adds credential rotation without changing existing credential inputs', () => { const issueInput = { principalKind: 'remote_owner', @@ -858,6 +862,66 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('keeps compound proxy credentials write-only on the protocol', () => { + const operation = RUNTIME_POLICY_OPERATION_SPECS['runtime.policy.network-proxy.update']; + const input = { + expectedPolicyRevision: 4, + expectedCredential: null, + networkProxy: { + enabled: true, + protocol: 'http' as const, + host: '127.0.0.1', + port: 7897, + authEnabled: true, + username: 'proxy-user', + bypassList: ['localhost'], + autoBypassDomains: ['127.0.0.1'], + }, + credential: { kind: 'replace' as const, secret: 'write-only-secret' }, + }; + assert.deepEqual(operation.decodeInput(input), input); + assert.deepEqual( + operation.decodeOutput({ + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + }, + }), + { + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + }, + }, + ); + assert.throws( + () => + operation.decodeOutput({ + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + secret: 'must-not-cross-wire', + }, + }), + isInvalidFrame, + ); + }); + test('encodes maximum legal tool output as one bounded frame without identity loss', () => { const chunks = [ ['CJK', '界'.repeat(TOOL_OUTPUT_DELTA_MAX_CHARS)], diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index dc03b56c84..96eaa316da 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -464,6 +464,79 @@ test('projects runtime policy CAS results without returning the committed snapsh }); }); +test('two clients cannot recreate a proxy credential after authentication is disabled', async () => { + await withCoordinator(async ({ coordinator, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const configured = await coordinator.handlers['runtime.policy.network-proxy.update']( + { + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + authEnabled: true, + username: 'proxy-user', + }, + credential: { kind: 'replace', secret: 'initial-secret' }, + }, + context, + ); + assert.equal(configured.ok, true); + if (!configured.ok || configured.result.kind !== 'committed') return; + assert.equal(configured.result.credentialStatus.configured, true); + if (!configured.result.credentialStatus.configured) return; + assert.equal(JSON.stringify(configured).includes('initial-secret'), false); + const sharedCredentialBasis = { + locator: configured.result.credentialStatus.locator, + credentialId: configured.result.credentialStatus.credentialId, + revision: configured.result.credentialStatus.revision, + }; + const configuredPolicy = await stores.runtimePolicy.getSnapshot(); + + const disabled = await coordinator.handlers['runtime.policy.network-proxy.update']( + { + expectedPolicyRevision: configured.result.revision, + expectedCredential: sharedCredentialBasis, + networkProxy: { + ...configuredPolicy.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }, + context, + ); + assert.equal(disabled.ok, true); + if (!disabled.ok || disabled.result.kind !== 'committed') return; + + // Client A prepared its request from the shared pre-disable basis, but its + // Host command arrives after client B's disable command has committed. + const stale = await coordinator.handlers['runtime.policy.network-proxy.update']( + { + expectedPolicyRevision: configured.result.revision, + expectedCredential: sharedCredentialBasis, + networkProxy: configuredPolicy.policy.networkProxy, + credential: { kind: 'replace', secret: 'must-not-return' }, + }, + context, + ); + assert.equal(stale.ok, true); + if (!stale.ok) return; + assert.ok( + stale.result.kind === 'revision_conflict' || stale.result.kind === 'credential_stale', + ); + + const finalPolicy = await stores.runtimePolicy.getSnapshot(); + const finalCredential = await stores.credentialVault.getStatus({ + scope: 'network_proxy', + kind: 'password', + }); + assert.equal(finalPolicy.policy.networkProxy.authEnabled, false); + assert.equal(finalCredential.kind, 'status'); + if (finalCredential.kind === 'status') assert.equal(finalCredential.status.configured, false); + }); +}); + test('rejects a Host-invalid shell preference before it reaches durable policy', async () => { await withCoordinator(async ({ stores }) => { const coordinator = new HostRuntimePolicyCoordinator( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9fe1549aa1..2a5b622c3b 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 61 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 62 as const; +// 62: Proxy policy and credentials commit through one recoverable Host command. +// Older peers can split the writes and violate the shared credential basis. // 61: Session explicit model targets carry immutable Connection identity, // configuration updates are Host-merged patches, and projections expose the // required nullable binding ID. Older peers cannot preserve these invariants. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 84595571bb..76fb45549e 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -282,6 +282,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'queue.entry.update', 'queue.retract', 'runtime.policy.mutate', + 'runtime.policy.network-proxy.update', 'runtime.policy.query', 'runtime.resource.controller.acquire', 'runtime.resource.controller.control', diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index edb88831eb..073e7da51a 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -39,6 +39,7 @@ import { normalizeDeleteCredentialInput, normalizeRemoveCatalogConnectionInput, normalizeOptionalRequestBodyOverlay, + normalizeNetworkProxyUpdate, normalizeRequestHeaderUpdates, normalizeRuntimePolicyMutation, normalizeSetCredentialInput, @@ -61,6 +62,7 @@ import { type RequestHeaderUpdate, type RevisionConflict, type RuntimePolicySnapshot, + type UpdateNetworkProxyInput, type SetCredentialInput, type SetDefaultConnectionTargetInput, type UpdateCatalogConnectionInput, @@ -104,6 +106,15 @@ export type RuntimePolicyMutateInput = MutateRuntimePolicyInput; export type RuntimePolicyMutateResult = | { readonly kind: 'committed'; readonly revision: number } | RevisionConflict; +export type RuntimePolicyNetworkProxyUpdateInput = UpdateNetworkProxyInput; +export type RuntimePolicyNetworkProxyUpdateResult = + | { + readonly kind: 'committed'; + readonly revision: number; + readonly credentialStatus: CredentialStatus; + } + | RevisionConflict + | CredentialStale; export type ConnectionCatalogCursor = | { readonly connectionIndex: number; readonly part: 'connection' } @@ -268,6 +279,17 @@ export const RUNTIME_POLICY_OPERATION_SPECS = { decodeInput: decodeRuntimePolicyMutation, decodeOutput: decodeRuntimePolicyMutationResult, }), + 'runtime.policy.network-proxy.update': defineOperation< + RuntimePolicyNetworkProxyUpdateInput, + RuntimePolicyNetworkProxyUpdateResult, + (typeof MUTATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATION_ERRORS, + decodeInput: decodeRuntimePolicyNetworkProxyUpdate, + decodeOutput: decodeRuntimePolicyNetworkProxyUpdateResult, + }), 'connection.catalog.query': defineOperation< ConnectionCatalogQueryInput, ConnectionCatalogQueryResult, @@ -413,6 +435,39 @@ function decodeRuntimePolicyMutationResult(value: unknown): RuntimePolicyMutateR return revisionConflict(item, 'runtime policy mutation result'); } +function decodeRuntimePolicyNetworkProxyUpdate( + value: unknown, +): RuntimePolicyNetworkProxyUpdateInput { + const input = decodeDomain(() => normalizeNetworkProxyUpdate(value)); + if ( + input.credential.kind === 'replace' && + Buffer.byteLength(input.credential.secret, 'utf8') > CREDENTIAL_SECRET_MAX_BYTES + ) { + throw invalidProtocolFrame('Invalid network proxy credential secret'); + } + return input; +} + +function decodeRuntimePolicyNetworkProxyUpdateResult( + value: unknown, +): RuntimePolicyNetworkProxyUpdateResult { + const item = requireRecord(value, 'network proxy update result'); + if (item.kind === 'committed') { + const committed = requireExactRecord(item, 'network proxy update committed result', [ + 'kind', + 'revision', + 'credentialStatus', + ]); + return { + kind: 'committed', + revision: revision(committed.revision, 'runtime policy revision'), + credentialStatus: decodeDomain(() => decodeCredentialStatus(committed.credentialStatus)), + }; + } + if (item.kind === 'credential_stale') return credentialStale(item); + return revisionConflict(item, 'network proxy update result'); +} + function decodeCatalogQueryInput(value: unknown): ConnectionCatalogQueryInput { const item = requireRecord(value, 'connection catalog query input'); if (item.kind === 'start') { diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 7f3e9dbcdf..17cb49b9f6 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -96,6 +96,7 @@ export class HostRuntimePolicyCoordinator { readonly handlers: RuntimePolicyOperationHandlerMap = { 'runtime.policy.query': () => this.#queryPolicy(), 'runtime.policy.mutate': (input) => this.#mutatePolicy(input), + 'runtime.policy.network-proxy.update': (input) => this.#updateNetworkProxy(input), 'connection.catalog.query': (input) => this.#queryCatalog(input), 'connection.catalog.create': (input) => this.#createConnection(input), 'connection.catalog.update': (input) => this.#updateConnection(input), @@ -144,6 +145,33 @@ export class HostRuntimePolicyCoordinator { }); } + async #updateNetworkProxy( + input: Parameters[0], + ): Promise> { + return this.#storeMutation(async () => { + try { + await this.validateMutation({ + expectedRevision: input.expectedPolicyRevision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + } catch (error) { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + 'Network proxy update failed Host validation', + { cause: error }, + ); + } + const result = await this.#stores.operations.updateNetworkProxy(input); + return result.kind === 'committed' + ? { + kind: 'committed' as const, + revision: result.snapshot.revision, + credentialStatus: result.credentialStatus, + } + : result; + }); + } + async #queryCatalog( input: ConnectionCatalogQueryInput, ): Promise> { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 2d1bbfa26f..e0bf42eceb 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -3023,6 +3023,131 @@ describe('runtime policy stores', () => { }); }); + test('a stale client cannot recreate a proxy credential after another client disables authentication', async () => { + await withInteractiveOwner(async ({ stores }) => { + const initialPolicy = await stores.runtimePolicy.getSnapshot(); + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initialPolicy.revision, + expectedCredential: null, + networkProxy: { + ...initialPolicy.policy.networkProxy, + enabled: true, + authEnabled: true, + username: 'proxy-user', + }, + credential: { kind: 'replace', secret: 'initial-secret' }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + assert.equal(configured.credentialStatus.configured, true); + if (!configured.credentialStatus.configured) return; + + // Both clients observed the same Host-owned policy and credential basis. + const clientAPolicyRevision = configured.snapshot.revision; + const clientACredential = credentialBasis(configured.credentialStatus); + const clientBPolicyRevision = configured.snapshot.revision; + const clientBCredential = credentialBasis(configured.credentialStatus); + + const disabled = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: clientBPolicyRevision, + expectedCredential: clientBCredential, + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }); + assert.equal(disabled.kind, 'committed'); + if (disabled.kind !== 'committed') return; + + const staleReplacement = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: clientAPolicyRevision, + expectedCredential: clientACredential, + networkProxy: configured.snapshot.policy.networkProxy, + credential: { kind: 'replace', secret: 'must-not-return' }, + }); + assert.ok( + staleReplacement.kind === 'revision_conflict' || + staleReplacement.kind === 'credential_stale', + ); + + const finalPolicy = await stores.runtimePolicy.getSnapshot(); + const finalCredential = await getCredentialStatus(stores.credentialVault, proxyCredential()); + assert.equal(finalPolicy.policy.networkProxy.authEnabled, false); + assert.equal(finalCredential.configured, false); + }); + }); + + test('proxy policy and credential replacement recover from every persistence cut', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + for (let failedSync = 1; failedSync <= 7; failedSync += 1) { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const target = { + ...initial.policy.networkProxy, + enabled: true, + host: 'recoverable.proxy.internal', + port: 7897, + authEnabled: true, + username: 'recoverable-user', + }; + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === failedSync) { + throw new Error(`injected proxy update persistence cut ${failedSync}`); + } + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: target, + credential: { kind: 'replace', secret: 'recoverable-secret' }, + }), + isStoreError(failedSync === 1 ? 'io_failed' : 'commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + assert.equal(syncCalls, failedSync); + + const policy = await stores.runtimePolicy.getSnapshot(); + const credential = await getCredentialStatus(stores.credentialVault, proxyCredential()); + if (failedSync === 1) { + assert.deepEqual(policy, initial); + assert.equal(credential.configured, false); + assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); + return; + } + + assert.deepEqual(policy.policy.networkProxy, target); + assert.equal(credential.configured, true); + assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); + const material = await stores.operations.exportCredentialMaterial(proxyCredential()); + assert.equal(material?.secret, 'recoverable-secret'); + }); + } + }); + test('blocks WebFetch while privacy mode is active', async () => { await withInteractiveOwner(async ({ stores }) => { const policy = await stores.runtimePolicy.mutate({ diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 98b3bc5574..499e0448bb 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -233,6 +233,7 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic delete: (input) => coordinator.deleteCredential(input), }, operations: { + updateNetworkProxy: (input) => coordinator.updateNetworkProxy(input), exportCredentialMaterial: (locator) => coordinator.exportCredentialMaterial(locator), getConnectionRequestHeaders: (connectionId) => coordinator.getConnectionRequestHeaders(connectionId), diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index f4a3418b95..7c51dcd1ba 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import { decodeConnectionModelId, decodeConnectionSlug, @@ -32,6 +33,7 @@ import { serializeRequestHeaders, RequestCustomizationValidationError, normalizeCredentialSecret, + normalizeNetworkProxyUpdate, type ConnectionCatalogEntry, type ConnectionCatalogSnapshot, type ConnectionVersionBasis, @@ -51,6 +53,8 @@ import { type MigrateSystemSeedInput, type SetDefaultConnectionTargetInput, type UpdateCatalogConnectionInput, + type UpdateNetworkProxyInput, + type UpdateNetworkProxyResult, } from '@maka/core/runtime-policy'; import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/provider-auth'; import { isRetiredProvider } from '@maka/core/provider-registry'; @@ -87,6 +91,7 @@ import { commitOutcomeUnknown, decodeConnectionInput, decodeCredentialInput, + decodePolicyInput, RuntimePolicyStoreError, } from './errors.js'; import { @@ -127,6 +132,13 @@ import { writeConnectionOnboardingIntent, type ConnectionOnboardingIntent, } from './onboarding-transaction.js'; +import { + clearNetworkProxyUpdateIntent, + prepareNetworkProxyUpdateIntent, + readNetworkProxyUpdateIntent, + writeNetworkProxyUpdateIntent, + type NetworkProxyUpdateIntent, +} from './network-proxy-transaction.js'; import { policySnapshot, RuntimePolicyDocumentOwner } from './policy-document.js'; import { SerializedOperationLane } from '../serialized-operation-lane.js'; @@ -228,6 +240,7 @@ export class RuntimePolicyCoordinator { private readonly vault = new CredentialVaultDocumentOwner(); private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; + private networkProxyRecoveryRequired = false; constructor(private readonly execute: RootExecutor) { this.lane = new SerializedOperationLane(execute); @@ -237,6 +250,7 @@ export class RuntimePolicyCoordinator { return this.lane.run(async (root) => { await cleanupRuntimePolicyDocumentTemps(root); await this.recoverConnectionOnboarding(root); + await this.recoverNetworkProxyUpdate(root); const catalog = await this.catalog.read(root); const vault = await this.vault.read(root); await this.vault.deleteOrphanedConnectionCredentials( @@ -299,6 +313,72 @@ export class RuntimePolicyCoordinator { }); } + updateNetworkProxy(rawInput: UpdateNetworkProxyInput): Promise { + return this.inLane(async (root) => { + const input = decodePolicyInput(() => normalizeNetworkProxyUpdate(rawInput)); + const policy = await this.policy.read(root); + const preparedPolicy = this.policy.prepareMutation(policy, { + expectedRevision: input.expectedPolicyRevision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + if (preparedPolicy.kind !== 'ready') return preparedPolicy; + + const vault = await this.vault.read(root); + const existing = findCredential(vault, networkProxyCredentialLocator()); + if (!matchesCredentialExpectation(existing, input.expectedCredential)) { + return deepFreeze({ + kind: 'credential_stale' as const, + expected: input.expectedCredential, + actual: existing ? credentialBasis(existing) : null, + }); + } + // Preflight every document before making the transaction intent durable. + // Once the intent exists, any later failure means recovery owns the target. + if (input.credential.kind === 'replace' && existing?.secret !== input.credential.secret) { + const prepared = this.vault.prepareSet(vault, { + locator: networkProxyCredentialLocator(), + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: input.credential.secret, + }); + if (prepared.kind !== 'ready') { + if (prepared.kind === 'credential_stale') return prepared; + throw codecError('invalid_credential_input', 'Network proxy credential is invalid'); + } + } else if (input.credential.kind === 'delete' && existing) { + const prepared = this.vault.prepareDelete(vault, { + expected: credentialBasis(existing), + }); + if (prepared.kind !== 'ready') { + if (prepared.kind === 'credential_stale') return prepared; + throw codecError('invalid_credential_input', 'Network proxy credential is invalid'); + } + } + + const intent = prepareNetworkProxyUpdateIntent(input); + try { + await writeNetworkProxyUpdateIntent(root, intent); + } catch (error) { + if (isCommitOutcomeUnknown(error)) this.networkProxyRecoveryRequired = true; + throw error; + } + this.networkProxyRecoveryRequired = true; + try { + const result = await this.applyNetworkProxyUpdate(root, intent); + await clearNetworkProxyUpdateIntent(root); + this.networkProxyRecoveryRequired = false; + return result; + } catch (error) { + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown( + 'Network proxy update has a durable intent and must recover before retrying', + error, + ); + } + }); + } + createConnection(input: CreateCatalogConnectionInput) { return this.inLane((root) => this.catalog.create(root, input)); } @@ -1564,6 +1644,85 @@ export class RuntimePolicyCoordinator { } } + private async recoverNetworkProxyUpdate(root: string): Promise { + const intent = await readNetworkProxyUpdateIntent(root); + if (!intent) { + this.networkProxyRecoveryRequired = false; + return; + } + this.networkProxyRecoveryRequired = true; + try { + await this.applyNetworkProxyUpdate(root, intent); + await clearNetworkProxyUpdateIntent(root); + this.networkProxyRecoveryRequired = false; + } catch (error) { + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown('Network proxy update recovery did not converge', error); + } + } + + private async applyNetworkProxyUpdate( + root: string, + intent: NetworkProxyUpdateIntent, + ): Promise> { + const policy = await this.policy.read(root); + const vault = await this.vault.read(root); + const locator = networkProxyCredentialLocator(); + const existing = findCredential(vault, locator); + const credentialChanged = + intent.credential.kind === 'replace' + ? existing?.secret !== intent.credential.secret + : intent.credential.kind === 'delete' && existing !== undefined; + const proxyChanged = !isDeepStrictEqual(policy.policy.networkProxy, intent.networkProxy); + const effectiveProxyChanged = !sameEffectiveProxyConfiguration( + effectiveProxyConfigurationBasis(policy.policy.networkProxy), + effectiveProxyConfigurationBasis(intent.networkProxy), + ); + if (credentialChanged || effectiveProxyChanged) { + await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); + } + + if (intent.credential.kind === 'replace' && credentialChanged) { + const prepared = this.vault.prepareSet(vault, { + locator, + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: intent.credential.secret, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential recovery became stale'); + } + await this.vault.commitSet(root, prepared); + } else if (intent.credential.kind === 'delete' && existing) { + const prepared = this.vault.prepareDelete(vault, { + expected: credentialBasis(existing), + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential deletion became stale'); + } + await this.vault.commitDelete(root, prepared); + } + + let snapshot = policySnapshot(policy); + if (proxyChanged) { + const prepared = this.policy.prepareMutation(policy, { + expectedRevision: policy.revision, + operation: { kind: 'set_network_proxy', value: intent.networkProxy }, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy policy recovery became stale'); + } + snapshot = (await this.policy.commitMutation(root, prepared)).snapshot; + } + const finalVault = await this.vault.read(root); + return deepFreeze({ + kind: 'committed' as const, + snapshot, + credentialStatus: credentialStatus(finalVault, locator), + }); + } + private async applyConnectionOnboarding( root: string, intent: ConnectionOnboardingIntent, @@ -1616,11 +1775,19 @@ export class RuntimePolicyCoordinator { private inLane(operation: (root: string) => Promise): Promise { return this.lane.run(async (root) => { if (this.onboardingRecoveryRequired) await this.recoverConnectionOnboarding(root); + if (this.networkProxyRecoveryRequired) await this.recoverNetworkProxyUpdate(root); return operation(root); }); } } +function matchesCredentialExpectation( + actual: ReturnType, + expected: CredentialVersionBasis | null, +): boolean { + return expected === null ? actual === undefined : sameCredentialBasis(actual, expected); +} + function isCommitOutcomeUnknown(error: unknown): error is RuntimePolicyStoreError { return error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown'; } diff --git a/packages/storage/src/runtime-policy/document-io.ts b/packages/storage/src/runtime-policy/document-io.ts index df7d3bc137..153db28052 100644 --- a/packages/storage/src/runtime-policy/document-io.ts +++ b/packages/storage/src/runtime-policy/document-io.ts @@ -35,7 +35,7 @@ export const VAULT_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; const RUNTIME_POLICY_TEMP_PATTERN = - /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; + /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding|runtime-policy-network-proxy)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; export async function cleanupRuntimePolicyDocumentTemps(root: string): Promise { let failure: unknown; diff --git a/packages/storage/src/runtime-policy/network-proxy-transaction.ts b/packages/storage/src/runtime-policy/network-proxy-transaction.ts new file mode 100644 index 0000000000..3b4aae06c7 --- /dev/null +++ b/packages/storage/src/runtime-policy/network-proxy-transaction.ts @@ -0,0 +1,96 @@ +/* + * 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 { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + normalizeNetworkProxyUpdate, + type NetworkProxyCredentialUpdate, + type RuntimePolicy, + type UpdateNetworkProxyInput, +} from '@maka/core/runtime-policy'; +import { syncDirectory } from '../stable-storage.js'; +import { record } from './codec.js'; +import { codecError, commitOutcomeUnknown, decodePersistedDomain, ioFailed } from './errors.js'; +import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; + +const FILE = 'runtime-policy-network-proxy.json'; +const SCHEMA_VERSION = 1 as const; +const MAX_BYTES = 128 * 1024; + +export interface NetworkProxyUpdateIntent { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly networkProxy: RuntimePolicy['networkProxy']; + readonly credential: NetworkProxyCredentialUpdate; +} + +export function prepareNetworkProxyUpdateIntent( + input: UpdateNetworkProxyInput, +): NetworkProxyUpdateIntent { + return { + schemaVersion: SCHEMA_VERSION, + networkProxy: structuredClone(input.networkProxy), + credential: structuredClone(input.credential), + }; +} + +export async function readNetworkProxyUpdateIntent( + root: string, +): Promise { + const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); + if (value === undefined) return undefined; + const raw = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'networkProxy', + 'credential', + ]); + if (raw.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + const normalized = decodePersistedDomain(() => + normalizeNetworkProxyUpdate({ + expectedPolicyRevision: 0, + expectedCredential: null, + networkProxy: raw.networkProxy, + credential: raw.credential, + }), + ); + return prepareNetworkProxyUpdateIntent(normalized); +} + +export function writeNetworkProxyUpdateIntent( + root: string, + intent: NetworkProxyUpdateIntent, +): Promise { + return writeJsonDocument(root, FILE, intent, MAX_BYTES); +} + +export async function clearNetworkProxyUpdateIntent(root: string): Promise { + try { + await unlink(join(root, FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw ioFailed(`${FILE} could not be removed`, error); + } + try { + await syncDirectory(root); + } catch (error) { + throw commitOutcomeUnknown(`${FILE} removal outcome is unknown`, error); + } +} diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index f947af87fa..c0c9c40cbc 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -28,6 +28,8 @@ import type { CredentialStatus, CredentialVersionBasis, RuntimePolicy, + UpdateNetworkProxyInput, + UpdateNetworkProxyResult, RequestHeaderUpdate, SavedRequestHeaders, } from '@maka/core/runtime-policy'; @@ -327,6 +329,7 @@ export type ReplaceConnectionRequestHeadersResult = | { readonly kind: 'connection_not_found' }; export interface RuntimePolicyOperationCoordinator { + updateNetworkProxy(input: UpdateNetworkProxyInput): Promise; exportCredentialMaterial( locator: CredentialLocator, ): Promise; From 59ba976013ae7b9a18d5feb2396fe60a0ff64368 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:18:00 +0800 Subject: [PATCH 03/20] chore(test): update Windows skip inventory Generated-by: OpenAI Codex --- docs/windows-test-inventory.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 53ffc8563a..f62138ec7f 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 25 | -| portable-candidate | 10 | +| portable-candidate | 11 | | platform-contract | 35 | -Total Windows-excluded declarations: **70** +Total Windows-excluded declarations: **71** ## Inventory @@ -89,6 +89,7 @@ Total Windows-excluded declarations: **70** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` validates proxy policy mutations before clearing and reports failed follow-up commits as unknown | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` reports unknown outcome when active proxy password persistence fails after clearing | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | +| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` proxy policy and credential replacement recover from every persistence cut | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` rejects a symlink instead of following it | `process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false` | From e72601eed50bc99eb15c3b449014d42ff73a85e4 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:08:52 +0800 Subject: [PATCH 04/20] fix(desktop): close proxy password review gaps Generated-by: OpenAI Codex --- .../e2e/proxy-password-editing.spec.ts | 25 +++++++++++- .../src/main/__tests__/password-input.test.ts | 40 +++++++++++++++++++ .../runtime-host-config-ipc-main.test.ts | 30 ++++++++++++++ .../runtime-host-settings-ipc-main.test.ts | 2 +- .../settings-test-result-copy.test.ts | 39 ++++++++++++++++++ .../src/main/runtime-host-config-ipc-main.ts | 5 ++- apps/desktop/src/main/settings-ipc-helpers.ts | 5 +++ .../locales/settings-test-result-copy.ts | 6 +++ .../settings/general-settings-page.tsx | 5 ++- .../src/renderer/settings/password-input.tsx | 7 +++- packages/core/src/settings.ts | 1 + .../settings-store-onboarding.test.ts | 39 ++++++++++++++++++ packages/storage/src/settings-store.ts | 21 +++++++++- 13 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts diff --git a/apps/desktop/e2e/proxy-password-editing.spec.ts b/apps/desktop/e2e/proxy-password-editing.spec.ts index 57766c299d..1921ccd223 100644 --- a/apps/desktop/e2e/proxy-password-editing.spec.ts +++ b/apps/desktop/e2e/proxy-password-editing.spec.ts @@ -25,6 +25,7 @@ test("proxy password drafts save once, reload safely, and authenticate offline", }) => { const username = "proxy-user"; const password = "complete-secret"; + const replacementPassword = "replacement-secret"; let acceptAuthorization!: (value: string | undefined) => void; const authorization = new Promise((resolve) => { acceptAuthorization = resolve; @@ -108,12 +109,34 @@ test("proxy password drafts save once, reload safely, and authenticate offline", await expect(reloadedPassword).toHaveValue(""); await expect(page.getByRole("button", { name: "复制" })).toHaveCount(0); + await reloadedPassword.pressSequentially("discarded-draft"); + await expect(reloadedPassword).toHaveValue("discarded-draft"); + await reloadedPassword.press("Escape"); + await expect(reloadedPassword).toBeVisible(); + await expect(reloadedPassword).toHaveValue(""); + + await reloadedPassword.pressSequentially(replacementPassword); + await eye.click(); + await expect(reloadedPassword).toHaveAttribute("type", "text"); + await expect(reloadedPassword).toHaveValue(replacementPassword); + await reloadedPassword.focus(); + await reloadedPassword.press("Enter"); + await expect(reloadedPassword).toHaveValue(""); + + await page.reload(); + await page.waitForSelector(COMPOSER_INPUT); + await page.getByRole("button", { name: "设置" }).click(); + await page.getByRole("button", { name: "通用", exact: true }).click(); + await expect( + page.getByPlaceholder("密码已保存;输入新密码以替换"), + ).toHaveValue(""); + 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")}`, + `Basic ${Buffer.from(`${username}:${replacementPassword}`).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 index b0a94c2924..4293553626 100644 --- a/apps/desktop/src/main/__tests__/password-input.test.ts +++ b/apps/desktop/src/main/__tests__/password-input.test.ts @@ -86,9 +86,38 @@ test("proxy password can hide Copy while ordinary password inputs keep it by def assert.equal(copyButtons.length, 1); }); +test("IME confirmation keys do not submit or cancel the password draft", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + const onKeyDown = reactProps(input).onKeyDown as (event: { + key: string; + nativeEvent: { isComposing?: boolean }; + }) => void; + + onKeyDown({ key: "Enter", nativeEvent: { isComposing: true } }); + onKeyDown({ key: "Escape", nativeEvent: { isComposing: true } }); + onKeyDown({ key: "Process", nativeEvent: {} }); + + assert.deepEqual(harness.keyEvents, { enters: 0, keys: [] }); +}); + +test("non-composing Enter submits once and remains observable by the caller", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + const onKeyDown = reactProps(input).onKeyDown as (event: { + key: string; + nativeEvent: { isComposing?: boolean }; + }) => void; + + onKeyDown({ key: "Enter", nativeEvent: { isComposing: false } }); + + assert.deepEqual(harness.keyEvents, { enters: 1, keys: ["Enter"] }); +}); + async function renderPasswordInputs(): Promise<{ document: Document; readonly exits: number; + readonly keyEvents: { enters: number; keys: string[] }; focusExit(from: Element, to: Element): void; }> { const { document, window } = parseHTML( @@ -107,6 +136,8 @@ async function renderPasswordInputs(): Promise<{ const root = createRoot(container); mountedRoot = root; let exits = 0; + let enters = 0; + const keys: string[] = []; await act(async () => { root.render( createElement(LocaleProvider, { @@ -120,6 +151,12 @@ async function renderPasswordInputs(): Promise<{ onFocusExit: () => { exits += 1; }, + onEnter: () => { + enters += 1; + }, + onKeyDown: (event) => { + keys.push(event.key); + }, hasCopyAction: false, label: "Proxy password", }), @@ -145,6 +182,9 @@ async function renderPasswordInputs(): Promise<{ get exits() { return exits; }, + get keyEvents() { + return { enters, keys: [...keys] }; + }, focusExit(_from, to) { const handler = reactProps(group).onBlurCapture as (event: { currentTarget: Element; 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 70a3b89b25..56ed5dff03 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 @@ -113,6 +113,36 @@ test('Runtime Host config export reads selected credentials from Host authority' assert.equal(settings.botChat.channels.telegram.token, 'bot-secret'); }); +test('Runtime Host config export includes settings when credentials are selected alone', async () => { + const bundle = await gatherRuntimeHostConfig( + ['credentials'], + { + client: { + loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => { + const secret = secretFor(locator); + return { + credential: + secret === null + ? null + : { + locator, + secretBase64: Buffer.from(secret).toString('base64'), + }, + }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + const settings = bundle.data.settings as Record; + assert.deepEqual(bundle.includedData, ['settings', 'credentials']); + assert.equal(settings.network.proxy.password, 'proxy-host'); + assert.equal(settings.webSearch.providers.tavily.apiKey, 'tavily-host'); +}); + test('Runtime Host config export writes an empty v1 proxy password when none is configured', async () => { const bundle = await gatherRuntimeHostConfig( ['settings', 'credentials'], 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 4ac2a48b75..6718679726 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 @@ -94,7 +94,7 @@ test("proxy test preserves enabled authentication when credentials are empty", a assert.equal(tested.candidate.authEnabled, true); assert.equal(tested.result.ok, false); - assert.equal(tested.result.code, "proxy_unreachable"); + assert.equal(tested.result.code, "proxy_credential_missing"); }); test("proxy test preserves disabled authentication for a local proxy", async () => { diff --git a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts new file mode 100644 index 0000000000..757ced374c --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.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 assert from "node:assert/strict"; +import test from "node:test"; +import { settingsTestResultMessage } from "../../renderer/locales/settings-test-result-copy.js"; + +test("missing proxy credentials have actionable bilingual copy", () => { + const result = { + ok: false, + code: "proxy_credential_missing", + message: "Proxy credential is not configured", + } as never; + + assert.equal( + settingsTestResultMessage(result, "zh"), + "代理认证已开启,请输入代理密码后再测试。", + ); + assert.equal( + settingsTestResultMessage(result, "en"), + "Proxy authentication is enabled. Enter a proxy password before testing.", + ); +}); 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 73146fbb0f..66f280753e 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -189,7 +189,10 @@ export async function gatherRuntimeHostConfig( if (selected.has('connections') && catalog) { data.connections = projectHostConnections(catalog); } - if (selected.has('settings')) { + // Schema v1 stores the network-proxy password and Tavily key in the + // settings payload. Keep a credentials-only request lossless by making the + // dependency explicit in the generated bundle. + if (selected.has('settings') || selected.has('credentials')) { const settings = await deps.getSettings(); data.settings = selected.has('credentials') ? restoreHostSettingsSecrets(settings, secrets) diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index 9443c932da..01708ecaa0 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -51,6 +51,11 @@ export function proxyTestFailure(result: TestProxyResult): { code: "proxy_configuration_missing", message: "The proxy host or port is missing.", }; + if (lower.includes("proxy credential is not configured")) + return { + code: "proxy_credential_missing", + message: "The proxy credential is not configured.", + }; if (lower.includes("proxy test timeout") || lower.includes("timeout")) return { code: "proxy_timeout", message: "The proxy test timed out." }; if (result.status) diff --git a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts index 66d8a316f0..48a4097fb9 100644 --- a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts @@ -29,6 +29,7 @@ type SettingsTestResultCopy = { ) => string; disabled: string; configurationMissing: string; + credentialMissing: string; timeout: string; httpError: (status: number | undefined) => string; unreachable: string; @@ -49,6 +50,7 @@ const COPY = { ["代理配置有效", endpoint, location].filter(Boolean).join(" · "), disabled: "请先启用代理服务器,再进行测试。", configurationMissing: "请填写代理服务器地址和端口后再测试。", + credentialMissing: "代理认证已开启,请输入代理密码后再测试。", timeout: "代理测试超时,请检查代理服务是否可达。", httpError: (status) => status === undefined @@ -75,6 +77,8 @@ const COPY = { .join(" · "), disabled: "Enable the proxy server before testing it.", configurationMissing: "Enter a proxy host and port before testing it.", + credentialMissing: + "Proxy authentication is enabled. Enter a proxy password before testing.", timeout: "The proxy test timed out. Check whether the proxy service is reachable.", httpError: (status) => @@ -115,6 +119,8 @@ export function settingsTestResultMessage( return copy.proxy.disabled; case "proxy_configuration_missing": return copy.proxy.configurationMissing; + case "proxy_credential_missing": + return copy.proxy.credentialMissing; case "proxy_timeout": return copy.proxy.timeout; case "proxy_http_error": diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 6a9cd14ebd..b69b2feefc 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -928,7 +928,10 @@ function NetworkProxySection(props: { onFocusExit={() => void passwordDraft.commit().catch(() => {})} onEnter={() => void passwordDraft.commit().catch(() => {})} onKeyDown={(event) => { - if (event.key === "Escape") passwordDraft.cancel(); + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + passwordDraft.cancel(); }} hasCopyAction={false} placeholder={ diff --git a/apps/desktop/src/renderer/settings/password-input.tsx b/apps/desktop/src/renderer/settings/password-input.tsx index 82e1681205..9303101f68 100644 --- a/apps/desktop/src/renderer/settings/password-input.tsx +++ b/apps/desktop/src/renderer/settings/password-input.tsx @@ -149,8 +149,11 @@ export function PasswordInput(props: { type={visible ? 'text' : 'password'} value={props.value} onChange={(value) => props.onChange(value)} - onEnter={props.onEnter} - onKeyDown={props.onKeyDown} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.key === 'Process') return; + if (event.key === 'Enter') props.onEnter?.(); + props.onKeyDown?.(event); + }} placeholder={props.placeholder} label={copy.value} isLabelHidden diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index f066f9664c..b344359da4 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -661,6 +661,7 @@ export type SettingsTestResultCode = | 'proxy_reachable' | 'proxy_disabled' | 'proxy_configuration_missing' + | 'proxy_credential_missing' | 'proxy_timeout' | 'proxy_http_error' | 'proxy_unreachable' diff --git a/packages/storage/src/__tests__/settings-store-onboarding.test.ts b/packages/storage/src/__tests__/settings-store-onboarding.test.ts index 3852f11019..525b41af13 100644 --- a/packages/storage/src/__tests__/settings-store-onboarding.test.ts +++ b/packages/storage/src/__tests__/settings-store-onboarding.test.ts @@ -270,6 +270,45 @@ describe('SettingsStore.updateIf', () => { }); describe('SettingsStore.get file recovery', () => { + it('atomically removes legacy proxy credentials from settings.json on read', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-proxy-migration-')); + try { + const store = createSettingsStore(workspaceRoot); + const settingsPath = join(workspaceRoot, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + schemaVersion: 1, + network: { + proxy: { + enabled: true, + host: '127.0.0.1', + port: 7897, + password: 'legacy-plaintext-secret', + passwordConfigured: true, + }, + }, + }), + 'utf8', + ); + + const settings = await store.get(); + const migrated = JSON.parse(await readFile(settingsPath, 'utf8')) as { + network: { proxy: Record }; + }; + + assert.equal(settings.network.proxy.host, '127.0.0.1'); + assert.equal('password' in migrated.network.proxy, false); + assert.equal('passwordConfigured' in migrated.network.proxy, false); + assert.equal( + (await readFile(settingsPath, 'utf8')).includes('legacy-plaintext-secret'), + false, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it('serializes concurrent first reads while creating default settings', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-concurrent-defaults-')); const originalNow = Date.now; diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts index 43250ae069..30dec6562d 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -100,7 +100,12 @@ class FileSettingsStore implements SettingsStore { private async readOrCreate(): Promise { try { const text = await readFile(this.settingsPath, 'utf8'); - return normalizeSettings(JSON.parse(text)); + const persisted: unknown = JSON.parse(text); + const settings = normalizeSettings(persisted); + if (hasLegacyProxyCredentialFields(persisted)) { + await this.write(settings); + } + return settings; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; const settings = createDefaultSettings(); @@ -231,3 +236,17 @@ class FileSettingsStore implements SettingsStore { return next; } } + +function hasLegacyProxyCredentialFields(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.network) || !isRecord(value.network.proxy)) { + return false; + } + const proxy = value.network.proxy; + return ['password', 'passwordConfigured', 'credential'].some((key) => + Object.prototype.hasOwnProperty.call(proxy, key), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From c9bec158de789e67f95e190a420ea31ee349a985 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:09:30 +0800 Subject: [PATCH 05/20] ci: retrigger checks Generated-by: OpenAI Codex From a35028f0faf9b57b6a3e0ed8f3f3d5b5e02b28ed Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:42:48 +0800 Subject: [PATCH 06/20] fix(storage): keep proxy secrets inside credential vault Generated-by: OpenAI Codex --- .../__tests__/runtime-policy-stores.test.ts | 171 ++++++++++++------ .../storage/src/runtime-policy/coordinator.ts | 156 +++++++--------- .../storage/src/runtime-policy/document-io.ts | 2 +- .../network-proxy-transaction.ts | 96 ---------- 4 files changed, 184 insertions(+), 241 deletions(-) delete mode 100644 packages/storage/src/runtime-policy/network-proxy-transaction.ts diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index e0bf42eceb..11fe421453 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -3079,73 +3079,132 @@ describe('runtime policy stores', () => { }); }); - test('proxy policy and credential replacement recover from every persistence cut', { + test('proxy replacement never persists its secret outside the credential vault', { skip: process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false, }, async () => { - for (let failedSync = 1; failedSync <= 7; failedSync += 1) { - await withInteractiveOwner(async ({ root, stores }) => { - const initial = await stores.runtimePolicy.getSnapshot(); - const target = { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'vault-only-proxy-secret'; + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy policy persistence failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'vault-only.proxy.internal', + port: 7897, + authEnabled: true, + username: 'vault-only-user', + }, + credential: { kind: 'replace', secret }, + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + const filesContainingSecret: string[] = []; + for (const entry of await readdir(root)) { + if (!entry.endsWith('.json')) continue; + if ((await readFile(join(root, entry), 'utf8')).includes(secret)) { + filesContainingSecret.push(entry); + } + } + assert.deepEqual(filesContainingSecret, ['credential-vault.json']); + assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); + assert.equal(existsSync(join(root, 'runtime-policy.json')), false); + }); + }); + + test('disabling proxy authentication commits policy before deleting its credential', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'retained-disabled-proxy-secret'; + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { ...initial.policy.networkProxy, enabled: true, - host: 'recoverable.proxy.internal', + host: 'disable-order.proxy.internal', port: 7897, authEnabled: true, - username: 'recoverable-user', - }; - const probe = await open(root, 'r'); - const fileHandlePrototype = Object.getPrototypeOf(probe) as { - sync: typeof probe.sync; - }; - const originalSync = fileHandlePrototype.sync; - await probe.close(); - let syncCalls = 0; - const syncMock = mock.method( - fileHandlePrototype, - 'sync', - async function (this: typeof probe) { - syncCalls += 1; - if (syncCalls === failedSync) { - throw new Error(`injected proxy update persistence cut ${failedSync}`); - } - return originalSync.call(this); - }, - ); + username: 'disable-order-user', + }, + credential: { kind: 'replace', secret }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; - try { - await assert.rejects( - stores.operations.updateNetworkProxy({ - expectedPolicyRevision: initial.revision, - expectedCredential: null, - networkProxy: target, - credential: { kind: 'replace', secret: 'recoverable-secret' }, - }), - isStoreError(failedSync === 1 ? 'io_failed' : 'commit_outcome_unknown'), - ); - } finally { - syncMock.mock.restore(); - } - assert.equal(syncCalls, failedSync); - - const policy = await stores.runtimePolicy.getSnapshot(); - const credential = await getCredentialStatus(stores.credentialVault, proxyCredential()); - if (failedSync === 1) { - assert.deepEqual(policy, initial); - assert.equal(credential.configured, false); - assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); - return; - } + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy credential deletion failure'); + return originalSync.call(this); + }, + ); - assert.deepEqual(policy.policy.networkProxy, target); - assert.equal(credential.configured, true); - assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); - const material = await stores.operations.exportCredentialMaterial(proxyCredential()); - assert.equal(material?.secret, 'recoverable-secret'); - }); - } + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: configured.snapshot.revision, + expectedCredential: credentialBasis(configured.credentialStatus), + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + const persistedPolicy = JSON.parse( + await readFile(join(root, 'runtime-policy.json'), 'utf8'), + ) as { readonly policy: { readonly networkProxy: RuntimePolicy['networkProxy'] } }; + assert.equal(persistedPolicy.policy.networkProxy.authEnabled, false); + assert.ok((await readFile(join(root, 'credential-vault.json'), 'utf8')).includes(secret)); + }); }); test('blocks WebFetch while privacy mode is active', async () => { diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 7c51dcd1ba..d7b9ef3f84 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -132,13 +132,6 @@ import { writeConnectionOnboardingIntent, type ConnectionOnboardingIntent, } from './onboarding-transaction.js'; -import { - clearNetworkProxyUpdateIntent, - prepareNetworkProxyUpdateIntent, - readNetworkProxyUpdateIntent, - writeNetworkProxyUpdateIntent, - type NetworkProxyUpdateIntent, -} from './network-proxy-transaction.js'; import { policySnapshot, RuntimePolicyDocumentOwner } from './policy-document.js'; import { SerializedOperationLane } from '../serialized-operation-lane.js'; @@ -240,7 +233,6 @@ export class RuntimePolicyCoordinator { private readonly vault = new CredentialVaultDocumentOwner(); private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; - private networkProxyRecoveryRequired = false; constructor(private readonly execute: RootExecutor) { this.lane = new SerializedOperationLane(execute); @@ -250,7 +242,6 @@ export class RuntimePolicyCoordinator { return this.lane.run(async (root) => { await cleanupRuntimePolicyDocumentTemps(root); await this.recoverConnectionOnboarding(root); - await this.recoverNetworkProxyUpdate(root); const catalog = await this.catalog.read(root); const vault = await this.vault.read(root); await this.vault.deleteOrphanedConnectionCredentials( @@ -332,8 +323,7 @@ export class RuntimePolicyCoordinator { actual: existing ? credentialBasis(existing) : null, }); } - // Preflight every document before making the transaction intent durable. - // Once the intent exists, any later failure means recovery owns the target. + // Preflight every document before publishing either side of the compound update. if (input.credential.kind === 'replace' && existing?.secret !== input.credential.secret) { const prepared = this.vault.prepareSet(vault, { locator: networkProxyCredentialLocator(), @@ -356,26 +346,7 @@ export class RuntimePolicyCoordinator { } } - const intent = prepareNetworkProxyUpdateIntent(input); - try { - await writeNetworkProxyUpdateIntent(root, intent); - } catch (error) { - if (isCommitOutcomeUnknown(error)) this.networkProxyRecoveryRequired = true; - throw error; - } - this.networkProxyRecoveryRequired = true; - try { - const result = await this.applyNetworkProxyUpdate(root, intent); - await clearNetworkProxyUpdateIntent(root); - this.networkProxyRecoveryRequired = false; - return result; - } catch (error) { - if (isCommitOutcomeUnknown(error)) throw error; - throw commitOutcomeUnknown( - 'Network proxy update has a durable intent and must recover before retrying', - error, - ); - } + return this.applyNetworkProxyUpdate(root, input); }); } @@ -1644,76 +1615,86 @@ export class RuntimePolicyCoordinator { } } - private async recoverNetworkProxyUpdate(root: string): Promise { - const intent = await readNetworkProxyUpdateIntent(root); - if (!intent) { - this.networkProxyRecoveryRequired = false; - return; - } - this.networkProxyRecoveryRequired = true; - try { - await this.applyNetworkProxyUpdate(root, intent); - await clearNetworkProxyUpdateIntent(root); - this.networkProxyRecoveryRequired = false; - } catch (error) { - if (isCommitOutcomeUnknown(error)) throw error; - throw commitOutcomeUnknown('Network proxy update recovery did not converge', error); - } - } - private async applyNetworkProxyUpdate( root: string, - intent: NetworkProxyUpdateIntent, + input: UpdateNetworkProxyInput, ): Promise> { const policy = await this.policy.read(root); const vault = await this.vault.read(root); const locator = networkProxyCredentialLocator(); const existing = findCredential(vault, locator); const credentialChanged = - intent.credential.kind === 'replace' - ? existing?.secret !== intent.credential.secret - : intent.credential.kind === 'delete' && existing !== undefined; - const proxyChanged = !isDeepStrictEqual(policy.policy.networkProxy, intent.networkProxy); + input.credential.kind === 'replace' + ? existing?.secret !== input.credential.secret + : input.credential.kind === 'delete' && existing !== undefined; + const proxyChanged = !isDeepStrictEqual(policy.policy.networkProxy, input.networkProxy); const effectiveProxyChanged = !sameEffectiveProxyConfiguration( effectiveProxyConfigurationBasis(policy.policy.networkProxy), - effectiveProxyConfigurationBasis(intent.networkProxy), + effectiveProxyConfigurationBasis(input.networkProxy), ); - if (credentialChanged || effectiveProxyChanged) { - await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); - } + const cleared = + credentialChanged || effectiveProxyChanged + ? await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)) + : false; + let durableChange = cleared; + let snapshot = policySnapshot(policy); + try { + const commitCredential = async (): Promise => { + if (input.credential.kind === 'replace' && credentialChanged) { + const prepared = this.vault.prepareSet(vault, { + locator, + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: input.credential.secret, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential update became stale'); + } + await this.vault.commitSet(root, prepared); + durableChange = true; + } else if (input.credential.kind === 'delete' && existing) { + const prepared = this.vault.prepareDelete(vault, { + expected: credentialBasis(existing), + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential deletion became stale'); + } + await this.vault.commitDelete(root, prepared); + durableChange = true; + } + }; - if (intent.credential.kind === 'replace' && credentialChanged) { - const prepared = this.vault.prepareSet(vault, { - locator, - expected: existing - ? { credentialId: existing.credentialId, revision: existing.revision } - : null, - secret: intent.credential.secret, - }); - if (prepared.kind !== 'ready') { - throw codecError('invalid_document', 'Network proxy credential recovery became stale'); - } - await this.vault.commitSet(root, prepared); - } else if (intent.credential.kind === 'delete' && existing) { - const prepared = this.vault.prepareDelete(vault, { - expected: credentialBasis(existing), - }); - if (prepared.kind !== 'ready') { - throw codecError('invalid_document', 'Network proxy credential deletion became stale'); - } - await this.vault.commitDelete(root, prepared); - } + const commitPolicy = async (): Promise => { + if (!proxyChanged) return; + const prepared = this.policy.prepareMutation(policy, { + expectedRevision: policy.revision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy policy update became stale'); + } + snapshot = (await this.policy.commitMutation(root, prepared)).snapshot; + durableChange = true; + }; - let snapshot = policySnapshot(policy); - if (proxyChanged) { - const prepared = this.policy.prepareMutation(policy, { - expectedRevision: policy.revision, - operation: { kind: 'set_network_proxy', value: intent.networkProxy }, - }); - if (prepared.kind !== 'ready') { - throw codecError('invalid_document', 'Network proxy policy recovery became stale'); + // Never leave an enabled policy pointing at an absent credential: publish a + // replacement before enabling its use, and retire credential use before deletion. + if ( + input.credential.kind === 'delete' && + !requiresNetworkProxyCredential(input.networkProxy) + ) { + await commitPolicy(); + await commitCredential(); + } else { + await commitCredential(); + await commitPolicy(); } - snapshot = (await this.policy.commitMutation(root, prepared)).snapshot; + } catch (error) { + if (durableChange && !isCommitOutcomeUnknown(error)) { + throw commitOutcomeUnknown('Network proxy update committed only some effects', error); + } + throw error; } const finalVault = await this.vault.read(root); return deepFreeze({ @@ -1775,7 +1756,6 @@ export class RuntimePolicyCoordinator { private inLane(operation: (root: string) => Promise): Promise { return this.lane.run(async (root) => { if (this.onboardingRecoveryRequired) await this.recoverConnectionOnboarding(root); - if (this.networkProxyRecoveryRequired) await this.recoverNetworkProxyUpdate(root); return operation(root); }); } diff --git a/packages/storage/src/runtime-policy/document-io.ts b/packages/storage/src/runtime-policy/document-io.ts index 153db28052..df7d3bc137 100644 --- a/packages/storage/src/runtime-policy/document-io.ts +++ b/packages/storage/src/runtime-policy/document-io.ts @@ -35,7 +35,7 @@ export const VAULT_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; const RUNTIME_POLICY_TEMP_PATTERN = - /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding|runtime-policy-network-proxy)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; + /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; export async function cleanupRuntimePolicyDocumentTemps(root: string): Promise { let failure: unknown; diff --git a/packages/storage/src/runtime-policy/network-proxy-transaction.ts b/packages/storage/src/runtime-policy/network-proxy-transaction.ts deleted file mode 100644 index 3b4aae06c7..0000000000 --- a/packages/storage/src/runtime-policy/network-proxy-transaction.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { unlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { - normalizeNetworkProxyUpdate, - type NetworkProxyCredentialUpdate, - type RuntimePolicy, - type UpdateNetworkProxyInput, -} from '@maka/core/runtime-policy'; -import { syncDirectory } from '../stable-storage.js'; -import { record } from './codec.js'; -import { codecError, commitOutcomeUnknown, decodePersistedDomain, ioFailed } from './errors.js'; -import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; - -const FILE = 'runtime-policy-network-proxy.json'; -const SCHEMA_VERSION = 1 as const; -const MAX_BYTES = 128 * 1024; - -export interface NetworkProxyUpdateIntent { - readonly schemaVersion: typeof SCHEMA_VERSION; - readonly networkProxy: RuntimePolicy['networkProxy']; - readonly credential: NetworkProxyCredentialUpdate; -} - -export function prepareNetworkProxyUpdateIntent( - input: UpdateNetworkProxyInput, -): NetworkProxyUpdateIntent { - return { - schemaVersion: SCHEMA_VERSION, - networkProxy: structuredClone(input.networkProxy), - credential: structuredClone(input.credential), - }; -} - -export async function readNetworkProxyUpdateIntent( - root: string, -): Promise { - const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); - if (value === undefined) return undefined; - const raw = record(value, FILE, 'invalid_document', [ - 'schemaVersion', - 'networkProxy', - 'credential', - ]); - if (raw.schemaVersion !== SCHEMA_VERSION) { - throw codecError('invalid_document', `${FILE} has an unsupported schema version`); - } - const normalized = decodePersistedDomain(() => - normalizeNetworkProxyUpdate({ - expectedPolicyRevision: 0, - expectedCredential: null, - networkProxy: raw.networkProxy, - credential: raw.credential, - }), - ); - return prepareNetworkProxyUpdateIntent(normalized); -} - -export function writeNetworkProxyUpdateIntent( - root: string, - intent: NetworkProxyUpdateIntent, -): Promise { - return writeJsonDocument(root, FILE, intent, MAX_BYTES); -} - -export async function clearNetworkProxyUpdateIntent(root: string): Promise { - try { - await unlink(join(root, FILE)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw ioFailed(`${FILE} could not be removed`, error); - } - try { - await syncDirectory(root); - } catch (error) { - throw commitOutcomeUnknown(`${FILE} removal outcome is unknown`, error); - } -} From 756a283f933f32b31addbca2da58fbe8f9a83f7f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:48:54 +0800 Subject: [PATCH 07/20] ci: retrigger checks Generated-by: OpenAI Codex From 0e34e6534de585544d7046e9b3096fd9fc4a3392 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:30:49 +0800 Subject: [PATCH 08/20] chore(test): refresh Windows skip inventory Generated-by: OpenAI Codex --- docs/windows-test-inventory.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index f62138ec7f..4ec0b336a5 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 25 | -| portable-candidate | 11 | +| portable-candidate | 12 | | platform-contract | 35 | -Total Windows-excluded declarations: **71** +Total Windows-excluded declarations: **72** ## Inventory @@ -89,7 +89,8 @@ Total Windows-excluded declarations: **71** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` validates proxy policy mutations before clearing and reports failed follow-up commits as unknown | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` reports unknown outcome when active proxy password persistence fails after clearing | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | -| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` proxy policy and credential replacement recover from every persistence cut | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | +| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` proxy replacement never persists its secret outside the credential vault | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | +| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` disabling proxy authentication commits policy before deleting its credential | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` rejects a symlink instead of following it | `process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false` | From a431fc468524655ca70a8eae8d56be4ea9e0c5ae Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:12:50 +0800 Subject: [PATCH 09/20] fix(desktop): address proxy password review findings --- .../src/main/__tests__/password-input.test.ts | 33 ++++- .../runtime-host-config-ipc-main.test.ts | 9 +- .../runtime-host-settings-ipc-main.test.ts | 15 ++- .../__tests__/settings-ipc-helpers.test.ts | 27 ---- .../src/main/runtime-host-config-ipc-main.ts | 27 +++- .../main/runtime-host-settings-ipc-main.ts | 21 +--- apps/desktop/src/main/settings-ipc-helpers.ts | 40 ------ .../src/renderer/settings/password-input.tsx | 3 + .../core/src/settings/network-settings.ts | 2 +- .../src/__tests__/protocol.test.ts | 10 ++ .../src/protocol/network-proxy.ts | 9 +- .../src/server/network-proxy-coordinator.ts | 1 - .../__tests__/runtime-policy-stores.test.ts | 119 ++++++++++++++++++ packages/storage/src/settings-store.ts | 24 ---- 14 files changed, 211 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src/main/__tests__/password-input.test.ts b/apps/desktop/src/main/__tests__/password-input.test.ts index 4293553626..6a0c79c986 100644 --- a/apps/desktop/src/main/__tests__/password-input.test.ts +++ b/apps/desktop/src/main/__tests__/password-input.test.ts @@ -77,6 +77,26 @@ test("keyboard focus stays inside through Eye and commits once when Tab leaves t assert.equal(harness.exits, 1); }); +test("window blur does not commit a partially typed password draft", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + + harness.setDocumentFocused(false); + harness.focusExit(input, null); + + assert.equal(harness.exits, 0); +}); + +test("focus exit with no destination still commits while the document is focused", async () => { + const harness = await renderPasswordInputs(); + const input = harness.document.querySelector("input") as HTMLInputElement; + + harness.setDocumentFocused(true); + harness.focusExit(input, null); + + 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( @@ -118,7 +138,8 @@ async function renderPasswordInputs(): Promise<{ document: Document; readonly exits: number; readonly keyEvents: { enters: number; keys: string[] }; - focusExit(from: Element, to: Element): void; + focusExit(from: Element, to: Element | null): void; + setDocumentFocused(focused: boolean): void; }> { const { document, window } = parseHTML( '
', @@ -137,7 +158,12 @@ async function renderPasswordInputs(): Promise<{ mountedRoot = root; let exits = 0; let enters = 0; + let documentFocused = true; const keys: string[] = []; + Object.defineProperty(document, "hasFocus", { + configurable: true, + value: () => documentFocused, + }); await act(async () => { root.render( createElement(LocaleProvider, { @@ -188,10 +214,13 @@ async function renderPasswordInputs(): Promise<{ focusExit(_from, to) { const handler = reactProps(group).onBlurCapture as (event: { currentTarget: Element; - relatedTarget: Element; + relatedTarget: Element | null; }) => void; handler({ currentTarget: group, relatedTarget: to }); }, + setDocumentFocused(focused) { + documentFocused = focused; + }, }; } 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 56ed5dff03..fcd12e48be 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 @@ -113,7 +113,7 @@ test('Runtime Host config export reads selected credentials from Host authority' assert.equal(settings.botChat.channels.telegram.token, 'bot-secret'); }); -test('Runtime Host config export includes settings when credentials are selected alone', async () => { +test('Runtime Host credentials-only export includes only schema-v1 credential fields', async () => { const bundle = await gatherRuntimeHostConfig( ['credentials'], { @@ -137,10 +137,11 @@ test('Runtime Host config export includes settings when credentials are selected } as never, ); - const settings = bundle.data.settings as Record; assert.deepEqual(bundle.includedData, ['settings', 'credentials']); - assert.equal(settings.network.proxy.password, 'proxy-host'); - assert.equal(settings.webSearch.providers.tavily.apiKey, 'tavily-host'); + assert.deepEqual(bundle.data.settings, { + network: { proxy: { password: 'proxy-host' } }, + webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } }, + }); }); test('Runtime Host config export writes an empty v1 proxy password when none is configured', async () => { 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 6718679726..fc8ec1fa4f 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 @@ -44,12 +44,7 @@ async function testCandidate(authEnabled: boolean): Promise<{ const policy = createDefaultRuntimePolicy(); let candidate: TestCandidate | undefined; - registerRuntimeHostSettingsIpc({ - ipcMain: { - handle(channel, listener) { - handlers.set(channel, listener as (...args: unknown[]) => unknown); - }, - }, + const module = createRuntimeHostSettingsModule({ client: { async queryRuntimePolicy() { return { revision: 0, policy }; @@ -68,6 +63,14 @@ async function testCandidate(authEnabled: boolean): Promise<{ settingsStore: {} as never, async applyClientSettings() {}, }); + registerRuntimeHostSettingsIpc({ + ipcMain: { + handle(channel, listener) { + handlers.set(channel, listener as (...args: unknown[]) => unknown); + }, + }, + module, + }); const handler = handlers.get("settings:testNetworkProxy"); assert.ok(handler); 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 5cb745ba31..f905cba983 100644 --- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts @@ -24,7 +24,6 @@ import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings"; import { buildSettingsUpdateResult, maskAppSettings, - preserveSensitivePlaceholders, toSettingsTestResult, } from "../settings-ipc-helpers.js"; @@ -98,32 +97,6 @@ describe("settings IPC helpers", () => { assert.equal(masked.webSearch.providers.tavily.credentialSource, "saved"); }); - test("preserves placeholder values as stored secrets before persisting patches", () => { - const current = createDefaultSettings(); - current.botChat.channels.telegram.token = "stored-bot-token"; - current.botChat.channels.feishu.appSecret = "stored-feishu-secret"; - - const patch = preserveSensitivePlaceholders( - { - botChat: { - channels: { - telegram: { token: SENSITIVE_PLACEHOLDER, enabled: true }, - feishu: { appSecret: SENSITIVE_PLACEHOLDER, appId: "cli_123" }, - }, - }, - }, - current, - ); - - assert.equal(patch.botChat?.channels?.telegram?.token, "stored-bot-token"); - assert.equal(patch.botChat?.channels?.telegram?.enabled, true); - assert.equal( - patch.botChat?.channels?.feishu?.appSecret, - "stored-feishu-secret", - ); - assert.equal(patch.botChat?.channels?.feishu?.appId, "cli_123"); - }); - test("maps runtime bot test results as credential checks, not operational readiness", () => { const result = toSettingsTestResult("telegram", { ok: true, 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 66f280753e..45bbae2838 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -192,11 +192,13 @@ export async function gatherRuntimeHostConfig( // Schema v1 stores the network-proxy password and Tavily key in the // settings payload. Keep a credentials-only request lossless by making the // dependency explicit in the generated bundle. - if (selected.has('settings') || selected.has('credentials')) { + if (selected.has('settings')) { const settings = await deps.getSettings(); data.settings = selected.has('credentials') ? restoreHostSettingsSecrets(settings, secrets) : stripSettingsSecretsForExport(settings); + } else if (selected.has('credentials')) { + data.settings = projectHostSettingsSecrets(secrets); } if (selected.has('credentials') && catalog) { data.credentials = connectionCredentials(catalog.connections, secrets); @@ -410,6 +412,29 @@ function restoreHostSettingsSecrets( }; } +function projectHostSettingsSecrets( + secrets: ReadonlyMap, +): Record { + return { + network: { + proxy: { + password: + secrets.get(locatorKey({ scope: 'network_proxy', kind: 'password' })) ?? '', + }, + }, + webSearch: { + providers: { + tavily: { + apiKey: + secrets.get( + locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), + ) ?? '', + }, + }, + }, + }; +} + /** Convert schema-v1 wire secrets into the write-only Runtime Host contract. */ export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle { const settings = bundle.data.settings; 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 55d80b175b..36128b0504 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -103,12 +103,10 @@ const exclusiveRunners = new WeakMap< RuntimeHostSettingsExclusiveRunner >(); -type RuntimeHostSettingsIpcRegistrationDeps = - | RuntimeHostSettingsIpcDeps - | { - readonly ipcMain: ReconnectableReadIpcMain; - readonly module: RuntimeHostSettingsModule; - }; +interface RuntimeHostSettingsIpcRegistrationDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly module: RuntimeHostSettingsModule; +} export function createRuntimeHostSettingsModule( deps: RuntimeHostSettingsModuleDeps, @@ -160,8 +158,7 @@ export function runRuntimeHostSettingsExclusive( export function registerRuntimeHostSettingsIpc( deps: RuntimeHostSettingsIpcRegistrationDeps, ): void { - const module = - "module" in deps ? deps.module : createRuntimeHostSettingsModule(deps); + const module = deps.module; handleReconnectableRead(deps.ipcMain, "settings:get", async () => maskAppSettings(await module.get()), ); @@ -187,7 +184,7 @@ function toRuntimeHostProxyPolicy( ): RuntimePolicy["networkProxy"] { const username = proxy.username?.trim() ?? ""; const authEnabled = - proxy.authEnabled ?? Boolean(username || proxy.password); + proxy.authEnabled ?? Boolean(username); return { enabled: proxy.enabled, protocol: proxy.type, @@ -200,10 +197,6 @@ function toRuntimeHostProxyPolicy( }; } -function credentialOverride(value: string | undefined): string | undefined { - return !value || value === SENSITIVE_PLACEHOLDER ? undefined : value; -} - async function testNetworkProxyWithoutLane( client: RuntimeHostSettingsClient, input: TestProxyInput, @@ -212,10 +205,8 @@ async function testNetworkProxyWithoutLane( 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 } : {}), }); diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index 01708ecaa0..df8952d38b 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -70,46 +70,6 @@ export function proxyTestFailure(result: TestProxyResult): { }; } -export function preserveSensitivePlaceholders( - patch: UpdateAppSettingsInput, - current: AppSettings, -): UpdateAppSettingsInput { - const botChannels = patch.botChat?.channels - ? Object.fromEntries( - Object.entries(patch.botChat.channels).map( - ([provider, channelPatch]) => { - const currentChannel = - current.botChat.channels[provider as BotProvider]; - return [ - provider, - { - ...channelPatch, - ...(channelPatch?.token === SENSITIVE_PLACEHOLDER - ? { token: currentChannel.token } - : {}), - ...(channelPatch?.appSecret === SENSITIVE_PLACEHOLDER - ? { appSecret: currentChannel.appSecret } - : {}), - }, - ]; - }, - ), - ) - : undefined; - - return { - ...patch, - ...(botChannels - ? { - botChat: { - ...patch.botChat, - channels: botChannels, - }, - } - : {}), - }; -} - export function maskAppSettings( settings: RuntimeHostAppSettings, revealPatch?: UpdateAppSettingsInput, diff --git a/apps/desktop/src/renderer/settings/password-input.tsx b/apps/desktop/src/renderer/settings/password-input.tsx index 9303101f68..806322ceed 100644 --- a/apps/desktop/src/renderer/settings/password-input.tsx +++ b/apps/desktop/src/renderer/settings/password-input.tsx @@ -136,6 +136,9 @@ export function PasswordInput(props: { status={props.status} onBlurCapture={(event) => { const destination = event.relatedTarget; + if (!destination && !event.currentTarget.ownerDocument.hasFocus()) { + return; + } if ( destination && event.currentTarget.contains(destination as Node) diff --git a/packages/core/src/settings/network-settings.ts b/packages/core/src/settings/network-settings.ts index 51d044d352..25f8ee4aa8 100644 --- a/packages/core/src/settings/network-settings.ts +++ b/packages/core/src/settings/network-settings.ts @@ -68,7 +68,7 @@ export const NETWORK_DEFAULTS: RuntimeNetworkSettings = { preferIpv4: false, }; -export interface TestProxySettings extends ProxySettings { +export interface TestProxySettings extends Omit { authEnabled?: boolean; } diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 145372103b..a5e5a4dd66 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -862,6 +862,16 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('rejects password overrides on network proxy tests', () => { + assert.throws( + () => + HOST_OPERATION_SPECS['network-proxy.test'].decodeInput({ + password: 'must-not-cross-wire', + }), + isInvalidFrame, + ); + }); + test('keeps compound proxy credentials write-only on the protocol', () => { const operation = RUNTIME_POLICY_OPERATION_SPECS['runtime.policy.network-proxy.update']; const input = { diff --git a/packages/runtime-host/src/protocol/network-proxy.ts b/packages/runtime-host/src/protocol/network-proxy.ts index ed5f2b1187..0fb0e1818a 100644 --- a/packages/runtime-host/src/protocol/network-proxy.ts +++ b/packages/runtime-host/src/protocol/network-proxy.ts @@ -29,7 +29,6 @@ import { import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; -const SECRET_MAX_BYTES = 16 * 1024; const RESULT_MAX_BYTES = 8 * 1024; const ERRORS = [ 'host_not_ready', @@ -41,7 +40,6 @@ const ERRORS = [ export interface NetworkProxyTestInput { readonly networkProxy?: RuntimePolicy['networkProxy']; - readonly password?: string; readonly url?: string; readonly timeoutMs?: number; } @@ -67,17 +65,12 @@ function decodeNetworkProxyTestInput(value: unknown): NetworkProxyTestInput { value, 'network proxy test input', [], - ['networkProxy', 'password', 'url', 'timeoutMs'], + ['networkProxy', 'url', 'timeoutMs'], ); return { ...(input.networkProxy === undefined ? {} : { networkProxy: decodeNetworkProxy(input.networkProxy) }), - ...(input.password === undefined - ? {} - : { - password: requireUtf8String(input.password, 'network proxy password', SECRET_MAX_BYTES), - }), ...(input.url === undefined ? {} : { url: decodeProbeUrl(input.url) }), ...(input.timeoutMs === undefined ? {} diff --git a/packages/runtime-host/src/server/network-proxy-coordinator.ts b/packages/runtime-host/src/server/network-proxy-coordinator.ts index f87a4cfe4b..19008b95f6 100644 --- a/packages/runtime-host/src/server/network-proxy-coordinator.ts +++ b/packages/runtime-host/src/server/network-proxy-coordinator.ts @@ -40,7 +40,6 @@ export class HostNetworkProxyCoordinator { try { const resolved = await this.policy.resolveNetworkProxyExecution({ ...(input.networkProxy ? { networkProxy: input.networkProxy } : {}), - ...(input.password ? { secretOverride: input.password } : {}), }); if (resolved.kind === 'credential_not_configured') { return { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 11fe421453..5908d570e6 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -3079,6 +3079,59 @@ describe('runtime policy stores', () => { }); }); + test('proxy replacement failure before vault publication leaves both stores unchanged', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 1) throw new Error('injected proxy credential pre-publication failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'unchanged.proxy.internal', + authEnabled: true, + username: 'unchanged-user', + }, + credential: { kind: 'replace', secret: 'must-not-persist' }, + }), + isStoreError('io_failed'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.deepEqual(await stores.runtimePolicy.getSnapshot(), initial); + assert.equal( + (await getCredentialStatus(stores.credentialVault, proxyCredential())).configured, + false, + ); + }); + }); + test('proxy replacement never persists its secret outside the credential vault', { skip: process.platform === 'win32' @@ -3139,6 +3192,72 @@ describe('runtime policy stores', () => { }); }); + test('authentication disable failure before policy publication leaves both stores unchanged', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'unchanged-disabled-proxy-secret'; + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + authEnabled: true, + username: 'unchanged-disable-user', + }, + credential: { kind: 'replace', secret }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 1) throw new Error('injected proxy policy pre-publication failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: configured.snapshot.revision, + expectedCredential: credentialBasis(configured.credentialStatus), + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }), + isStoreError('io_failed'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.deepEqual(await stores.runtimePolicy.getSnapshot(), configured.snapshot); + assert.equal( + (await getCredentialStatus(stores.credentialVault, proxyCredential())).configured, + true, + ); + }); + }); + test('disabling proxy authentication commits policy before deleting its credential', { skip: process.platform === 'win32' diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts index 30dec6562d..82d2c6759b 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -21,7 +21,6 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { AppSettings, - SettingsTestResult, UpdateAppSettingsInput, UsageRange, UsageStats, @@ -52,7 +51,6 @@ export interface SettingsStore { predicate: (current: AppSettings) => boolean, patch: ConditionalSettingsPatch, ): Promise<{ applied: boolean; settings: AppSettings }>; - testNetworkProxy(): Promise; usageStats(range?: UsageRange): Promise; /** * PR110b: upsert a single onboarding milestone. Caller passes the @@ -197,28 +195,6 @@ class FileSettingsStore implements SettingsStore { return result; } - async testNetworkProxy(): Promise { - const started = Date.now(); - const settings = await this.get(); - const proxy = settings.network.proxy; - if (!proxy.enabled) { - return { ok: true, message: '代理未启用,当前会直接连接。', latencyMs: Date.now() - started }; - } - if (!proxy.host.trim()) return { ok: false, message: '代理服务器地址不能为空' }; - if (!Number.isInteger(proxy.port) || proxy.port <= 0 || proxy.port > 65535) { - return { ok: false, message: '代理端口必须在 1-65535 之间' }; - } - if (proxy.authEnabled && !proxy.username.trim()) { - return { ok: false, message: '启用代理认证后需要用户名' }; - } - return { - ok: true, - message: `代理配置有效:${proxy.protocol}://${proxy.host}:${proxy.port}`, - latencyMs: Date.now() - started, - details: { bypassList: proxy.bypassList, autoBypassDomains: proxy.autoBypassDomains }, - }; - } - async usageStats(range: UsageRange = '24h'): Promise { return readUsageStats(this.workspaceRoot, range); } From 8a595e5add7bf2ac3fb1da074805039ff4d258dd Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:02:24 +0800 Subject: [PATCH 10/20] chore(test): refresh Windows skip inventory Generated-by: OpenAI Codex --- docs/windows-test-inventory.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 4ec0b336a5..94f6add155 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 25 | -| portable-candidate | 12 | +| portable-candidate | 14 | | platform-contract | 35 | -Total Windows-excluded declarations: **72** +Total Windows-excluded declarations: **74** ## Inventory @@ -89,7 +89,9 @@ Total Windows-excluded declarations: **72** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` validates proxy policy mutations before clearing and reports failed follow-up commits as unknown | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` reports unknown outcome when active proxy password persistence fails after clearing | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` preserves unknown commit semantics and consumes the completion ticket | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | +| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` proxy replacement failure before vault publication leaves both stores unchanged | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | | portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` proxy replacement never persists its secret outside the credential vault | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | +| portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` authentication disable failure before policy publication leaves both stores unchanged | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | | portable-candidate | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` disabling proxy authentication commits policy before deleting its credential | `process.platform === 'win32' ? 'POSIX file handles are required to inject persistence failures' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | From 7e14d8934fc6b24fea13fb2e4e652dbe8daea5e1 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:10:06 +0800 Subject: [PATCH 11/20] fix(desktop): preserve credentials-only restores --- .../__tests__/config-transfer-service.test.ts | 22 +++++++ .../runtime-host-config-ipc-main.test.ts | 61 ++++++++++++++++++- .../src/main/config-transfer-service.ts | 29 ++++++--- .../src/main/runtime-host-config-ipc-main.ts | 45 ++++++++------ 4 files changed, 129 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index a904296104..3e89906f13 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -160,6 +160,28 @@ describe('config-transfer-service', () => { assert.deepEqual(setCreds, [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-new' }]); assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); }); + + it('writes a credentials-only bundle to an existing connection', async () => { + const { deps, saved, setCreds } = makeDeps(); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['credentials'] as const, + data: { + credentials: [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-restored' }], + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(saved, [], 'credentials-only import does not rewrite the connection'); + assert.deepEqual(setCreds, [ + { slug: 'deepseek-main', kind: 'api_key', value: 'sk-restored' }, + ]); + assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); + }); + it('restores the whole bundle when it carries a retained retired connection', async () => { // A backup taken before the retirement still lists the connection, and the // catalog refuses to create one. Before this was planned as skipped, the 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 fcd12e48be..d055e845ea 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 @@ -118,7 +118,7 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi ['credentials'], { client: { - loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + loadConnectionCatalog: async () => CATALOG, exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => { const secret = secretFor(locator); return { @@ -142,6 +142,65 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi network: { proxy: { password: 'proxy-host' } }, webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } }, }); + assert.deepEqual(bundle.data.credentials, [ + { slug: 'deepseek-main', kind: 'api_key', value: 'sk-host' }, + ]); +}); + +test('Runtime Host credentials-only export omits each absent settings-carried secret', async () => { + const cases = [ + { + presentScope: 'web_search', + expected: { + webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } }, + }, + }, + { + presentScope: 'network_proxy', + expected: { + network: { proxy: { password: 'proxy-host' } }, + }, + }, + { + presentScope: null, + expected: undefined, + }, + ] as const; + + for (const { presentScope, expected } of cases) { + const bundle = await gatherRuntimeHostConfig( + ['credentials'], + { + client: { + loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + exportConfigurationCredentials: async ({ + locator, + }: { + locator: CredentialLocator; + }) => { + const secret = locator.scope === presentScope ? secretFor(locator) : null; + return { + credential: + secret === null + ? null + : { + locator, + secretBase64: Buffer.from(secret).toString('base64'), + }, + }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + assert.deepEqual(bundle.data.settings, expected); + assert.deepEqual( + bundle.includedData, + expected === undefined ? ['credentials'] : ['settings', 'credentials'], + ); + } }); test('Runtime Host config export writes an empty v1 proxy password when none is configured', async () => { diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index a66cc53701..74caed1692 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -72,10 +72,10 @@ export async function applyConfigImport( deps: ConfigTransferDeps, ): Promise { const result: ConfigImportResult = {}; - // Credentials are only applied for connections actually written this import - // (created or overwritten). A slug the user chose to skip must not have its - // stored secret silently overwritten. - const appliedConnectionSlugs = new Set(); + // A connection snapshot limits credential writes to connections created or + // overwritten by this import. Credentials-only bundles instead bind to an + // already-existing logical connection with the same slug. + const credentialTargetSlugs = new Set(); if (Array.isArray(bundle.data.connections)) { const incoming = bundle.data.connections as LlmConnection[]; @@ -91,13 +91,25 @@ export async function applyConfigImport( ? reconcileConnectionAfterEnabledModelsChange(connection, connection.enabledModelIds) : null; await deps.connectionStore.save(selection ? { ...connection, ...selection } : connection); - appliedConnectionSlugs.add(connection.slug); + credentialTargetSlugs.add(connection.slug); } result.connections = { created: plan.create.length, overwritten: plan.overwrite.length, skipped: plan.skipped.length, }; + } else if ( + !bundle.includedData.includes('connections') && + Array.isArray(bundle.data.credentials) + ) { + // Without a connection snapshot, the credential slug names an existing + // logical connection directly. A bundle that does include connections + // still uses the create/overwrite set above so an explicit skip cannot + // overwrite the target's credential. + const existing = await deps.connectionStore.list(); + for (const connection of existing) { + credentialTargetSlugs.add(connection.slug); + } } if (bundle.data.settings && typeof bundle.data.settings === 'object') { @@ -116,10 +128,9 @@ export async function applyConfigImport( entry.value.length > 0 && VALID_CREDENTIAL_KINDS.has(entry.kind); if (!valid) continue; - // Only write a secret for a connection that was created or overwritten - // in this import. Skipped (or not-imported) slugs keep their existing - // stored secret untouched. - if (!appliedConnectionSlugs.has(entry.slug)) { + // Unknown targets and connections explicitly skipped by a connection + // snapshot keep their existing stored secret untouched. + if (!credentialTargetSlugs.has(entry.slug)) { skipped += 1; continue; } 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 45bbae2838..fe30adf108 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -198,7 +198,8 @@ export async function gatherRuntimeHostConfig( ? restoreHostSettingsSecrets(settings, secrets) : stripSettingsSecretsForExport(settings); } else if (selected.has('credentials')) { - data.settings = projectHostSettingsSecrets(secrets); + const settingsSecrets = projectHostSettingsSecrets(secrets); + if (settingsSecrets) data.settings = settingsSecrets; } if (selected.has('credentials') && catalog) { data.credentials = connectionCredentials(catalog.connections, secrets); @@ -414,24 +415,32 @@ function restoreHostSettingsSecrets( function projectHostSettingsSecrets( secrets: ReadonlyMap, -): Record { +): Record | undefined { + const proxy = secrets.get( + locatorKey({ scope: 'network_proxy', kind: 'password' }), + ); + const tavily = secrets.get( + locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), + ); + if (proxy === undefined && tavily === undefined) return undefined; + return { - network: { - proxy: { - password: - secrets.get(locatorKey({ scope: 'network_proxy', kind: 'password' })) ?? '', - }, - }, - webSearch: { - providers: { - tavily: { - apiKey: - secrets.get( - locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), - ) ?? '', - }, - }, - }, + ...(proxy === undefined + ? {} + : { + network: { + proxy: { password: proxy }, + }, + }), + ...(tavily === undefined + ? {} + : { + webSearch: { + providers: { + tavily: { apiKey: tavily }, + }, + }, + }), }; } From 97dfd64a229db61c6c30185fd09d1615a61ab24b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:10:05 +0800 Subject: [PATCH 12/20] fix(desktop): isolate proxy password draft feature --- apps/desktop/renderer-architecture.json | 2 +- .../src/main/__tests__/password-input.test.ts | 36 ++++++++++++++++++- .../__tests__/proxy-password-draft.test.ts | 2 +- .../controller}/use-proxy-password-draft.ts | 2 +- .../renderer/features/network-proxy/index.ts | 25 +++++++++++++ .../model}/proxy-password-draft.ts | 0 .../features/network-proxy/testing.ts | 24 +++++++++++++ .../ui/network-proxy-password-draft.tsx | 29 +++++++++++++++ .../settings/general-settings-page.tsx | 25 ++++++++----- .../src/renderer/settings/password-input.tsx | 2 +- 10 files changed, 133 insertions(+), 14 deletions(-) rename apps/desktop/src/renderer/{settings => features/network-proxy/controller}/use-proxy-password-draft.ts (97%) create mode 100644 apps/desktop/src/renderer/features/network-proxy/index.ts rename apps/desktop/src/renderer/{settings => features/network-proxy/model}/proxy-password-draft.ts (100%) create mode 100644 apps/desktop/src/renderer/features/network-proxy/testing.ts create mode 100644 apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2f8f24e25e..8349a00e47 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3083,6 +3083,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../features/network-proxy/index.js": 1, "../locales/settings-preferences-copy.js": 1, "../locales/settings-shared-copy.js": 1, "../locales/settings-test-result-copy.js": 1, @@ -3102,7 +3103,6 @@ "@maka/core/llm-connections": 1, "@maka/core/model-thinking": 1, "@maka/core/settings": 3, - "@maka/core/settings/network-settings": 1, "@maka/ui": 2, "react": 1 } diff --git a/apps/desktop/src/main/__tests__/password-input.test.ts b/apps/desktop/src/main/__tests__/password-input.test.ts index 6a0c79c986..56d88a688e 100644 --- a/apps/desktop/src/main/__tests__/password-input.test.ts +++ b/apps/desktop/src/main/__tests__/password-input.test.ts @@ -18,16 +18,22 @@ */ import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, test } from "node:test"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; +import { build } from "esbuild"; import { AstryxLocaleProvider, LocaleProvider, ToastProvider, } from "@maka/ui"; -import { PasswordInput } from "../../renderer/settings/password-input.js"; +import type * as PasswordInputModule from "../../renderer/settings/password-input.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "../../../../.."); const originalGlobals = { document: globalThis.document, @@ -141,6 +147,7 @@ async function renderPasswordInputs(): Promise<{ focusExit(from: Element, to: Element | null): void; setDocumentFocused(focused: boolean): void; }> { + const { PasswordInput } = await importPasswordInput(); const { document, window } = parseHTML( '
', ); @@ -224,6 +231,33 @@ async function renderPasswordInputs(): Promise<{ }; } +async function importPasswordInput(): Promise { + const outdir = await mkdtemp( + resolve(REPO_ROOT, "apps/desktop/dist/main/__tests__/password-input-"), + ); + const outfile = resolve(outdir, "password-input.mjs"); + await mkdir(dirname(outfile), { recursive: true }); + await build({ + entryPoints: [ + resolve( + REPO_ROOT, + "apps/desktop/src/renderer/settings/password-input.tsx", + ), + ], + outfile, + bundle: true, + packages: "external", + platform: "node", + format: "esm", + jsx: "automatic", + target: "node20", + logLevel: "silent", + }); + return (await import( + `${pathToFileURL(outfile).href}?t=${Date.now()}` + )) as typeof PasswordInputModule; +} + function reactProps(element: Element): Record { const key = Object.keys(element).find((candidate) => candidate.startsWith("__reactProps$"), diff --git a/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts b/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts index b5ed4c4e02..be3eb22fc7 100644 --- a/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts +++ b/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts @@ -22,7 +22,7 @@ import test from "node:test"; import { createProxyPasswordDraft, runAfterProxyPasswordCommit, -} from "../../renderer/settings/proxy-password-draft.js"; +} from "../../renderer/features/network-proxy/testing.js"; function deferred() { let resolve!: (value: T | PromiseLike) => void; diff --git a/apps/desktop/src/renderer/settings/use-proxy-password-draft.ts b/apps/desktop/src/renderer/features/network-proxy/controller/use-proxy-password-draft.ts similarity index 97% rename from apps/desktop/src/renderer/settings/use-proxy-password-draft.ts rename to apps/desktop/src/renderer/features/network-proxy/controller/use-proxy-password-draft.ts index 9cb66cb857..6db03e5f8d 100644 --- a/apps/desktop/src/renderer/settings/use-proxy-password-draft.ts +++ b/apps/desktop/src/renderer/features/network-proxy/controller/use-proxy-password-draft.ts @@ -21,7 +21,7 @@ import { useEffect, useReducer, useRef } from "react"; import { createProxyPasswordDraft, type ProxyPasswordDraft, -} from "./proxy-password-draft.js"; +} from "../model/proxy-password-draft.js"; export function useProxyPasswordDraft( save: (secret: string) => Promise, diff --git a/apps/desktop/src/renderer/features/network-proxy/index.ts b/apps/desktop/src/renderer/features/network-proxy/index.ts new file mode 100644 index 0000000000..dd610dbc1e --- /dev/null +++ b/apps/desktop/src/renderer/features/network-proxy/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +export { NetworkProxyPasswordDraft } from "./ui/network-proxy-password-draft.js"; +export { + runAfterProxyPasswordCommit, + type ProxyPasswordDraft, +} from "./model/proxy-password-draft.js"; +export type { TestProxyInput } from "@maka/core/settings/network-settings"; diff --git a/apps/desktop/src/renderer/settings/proxy-password-draft.ts b/apps/desktop/src/renderer/features/network-proxy/model/proxy-password-draft.ts similarity index 100% rename from apps/desktop/src/renderer/settings/proxy-password-draft.ts rename to apps/desktop/src/renderer/features/network-proxy/model/proxy-password-draft.ts diff --git a/apps/desktop/src/renderer/features/network-proxy/testing.ts b/apps/desktop/src/renderer/features/network-proxy/testing.ts new file mode 100644 index 0000000000..4b62254110 --- /dev/null +++ b/apps/desktop/src/renderer/features/network-proxy/testing.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { + createProxyPasswordDraft, + runAfterProxyPasswordCommit, + type ProxyPasswordDraft, +} from "./model/proxy-password-draft.js"; diff --git a/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx b/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx new file mode 100644 index 0000000000..6a9917d070 --- /dev/null +++ b/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx @@ -0,0 +1,29 @@ +/* + * 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 type { ReactNode } from "react"; +import { useProxyPasswordDraft } from "../controller/use-proxy-password-draft.js"; +import type { ProxyPasswordDraft } from "../model/proxy-password-draft.js"; + +export function NetworkProxyPasswordDraft(props: { + save(secret: string): Promise; + children(draft: ProxyPasswordDraft): ReactNode; +}) { + return props.children(useProxyPasswordDraft(props.save)); +} diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index b69b2feefc..63c8cd713d 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -36,7 +36,6 @@ import type { } from '@maka/core/settings'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; -import type { TestProxyInput } from "@maka/core/settings/network-settings"; import { buildChatModelChoices } from "@maka/core/chat-model-choice"; import { Button, @@ -61,8 +60,12 @@ 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 { + NetworkProxyPasswordDraft, + runAfterProxyPasswordCommit, + type ProxyPasswordDraft, + type TestProxyInput, +} from "../features/network-proxy/index.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"; @@ -786,7 +789,7 @@ function NetworkProxySection(props: { return update(patch); } - const passwordDraft = useProxyPasswordDraft(async (secret) => { + async function saveProxyPassword(secret: string) { try { await props.onUpdate({ network: { @@ -797,9 +800,9 @@ function NetworkProxySection(props: { reportNetworkSaveError(error); throw error; } - }); + } - async function testProxy() { + async function testProxy(passwordDraft: ProxyPasswordDraft) { if (!props.isInteractive) return; if (!proxyTestGuard.begin("test")) return; setTesting(true); @@ -838,7 +841,9 @@ function NetworkProxySection(props: { } return ( - <> + + {(passwordDraft) => ( + <> void testProxy()} + onClick={() => void testProxy(passwordDraft)} label={copy.testCurrent} /> )} - + + )} + ); } diff --git a/apps/desktop/src/renderer/settings/password-input.tsx b/apps/desktop/src/renderer/settings/password-input.tsx index 806322ceed..6629e9d3fa 100644 --- a/apps/desktop/src/renderer/settings/password-input.tsx +++ b/apps/desktop/src/renderer/settings/password-input.tsx @@ -35,7 +35,7 @@ import { useToast, useUiLocale, } from '@maka/ui'; -import { useActionGuard } from './use-action-guard.js'; +import { useActionGuard } from './use-action-guard'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; /** From 262bf0c9ab0d944e9579c6994113e3b211da342f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:27:45 +0800 Subject: [PATCH 13/20] fix(desktop): avoid untracked proxy surface --- .../renderer/features/network-proxy/index.ts | 12 +++++++- .../ui/network-proxy-password-draft.tsx | 29 ------------------- 2 files changed, 11 insertions(+), 30 deletions(-) delete mode 100644 apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx diff --git a/apps/desktop/src/renderer/features/network-proxy/index.ts b/apps/desktop/src/renderer/features/network-proxy/index.ts index dd610dbc1e..ef56eb868b 100644 --- a/apps/desktop/src/renderer/features/network-proxy/index.ts +++ b/apps/desktop/src/renderer/features/network-proxy/index.ts @@ -17,9 +17,19 @@ * under the License. */ -export { NetworkProxyPasswordDraft } from "./ui/network-proxy-password-draft.js"; +import type { ReactNode } from "react"; +import { useProxyPasswordDraft } from "./controller/use-proxy-password-draft.js"; +import type { ProxyPasswordDraft } from "./model/proxy-password-draft.js"; + export { runAfterProxyPasswordCommit, type ProxyPasswordDraft, } from "./model/proxy-password-draft.js"; export type { TestProxyInput } from "@maka/core/settings/network-settings"; + +export function NetworkProxyPasswordDraft(props: { + save(secret: string): Promise; + children(draft: ProxyPasswordDraft): ReactNode; +}) { + return props.children(useProxyPasswordDraft(props.save)); +} diff --git a/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx b/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx deleted file mode 100644 index 6a9917d070..0000000000 --- a/apps/desktop/src/renderer/features/network-proxy/ui/network-proxy-password-draft.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 type { ReactNode } from "react"; -import { useProxyPasswordDraft } from "../controller/use-proxy-password-draft.js"; -import type { ProxyPasswordDraft } from "../model/proxy-password-draft.js"; - -export function NetworkProxyPasswordDraft(props: { - save(secret: string): Promise; - children(draft: ProxyPasswordDraft): ReactNode; -}) { - return props.children(useProxyPasswordDraft(props.save)); -} From 802dde295fd481271cb0570393a6ff224a25303e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:31:16 +0800 Subject: [PATCH 14/20] fix(desktop): keep proxy test entry minimal --- apps/desktop/src/renderer/features/network-proxy/testing.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/renderer/features/network-proxy/testing.ts b/apps/desktop/src/renderer/features/network-proxy/testing.ts index 4b62254110..35853bba89 100644 --- a/apps/desktop/src/renderer/features/network-proxy/testing.ts +++ b/apps/desktop/src/renderer/features/network-proxy/testing.ts @@ -20,5 +20,4 @@ export { createProxyPasswordDraft, runAfterProxyPasswordCommit, - type ProxyPasswordDraft, } from "./model/proxy-password-draft.js"; From 10c18e72042eb7f5e1514677d82b07b6e1420c14 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:54:58 +0800 Subject: [PATCH 15/20] fix(desktop): expand sidebar in proxy e2e --- apps/desktop/e2e/proxy-password-editing.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/proxy-password-editing.spec.ts b/apps/desktop/e2e/proxy-password-editing.spec.ts index 1921ccd223..879d1045f9 100644 --- a/apps/desktop/e2e/proxy-password-editing.spec.ts +++ b/apps/desktop/e2e/proxy-password-editing.spec.ts @@ -18,7 +18,12 @@ */ import { createServer } from "node:http"; -import { test, expect, COMPOSER_INPUT } from "./fixtures"; +import { + test, + expect, + COMPOSER_INPUT, + ensureSidebarExpanded, +} from "./fixtures"; test("proxy password drafts save once, reload safely, and authenticate offline", async ({ window: page, @@ -45,6 +50,7 @@ test("proxy password drafts save once, reload safely, and authenticate offline", } try { + await ensureSidebarExpanded(page); await page.getByRole("button", { name: "设置" }).click(); await page.getByRole("button", { name: "通用", exact: true }).click(); await page.getByRole("switch", { name: "启用代理服务器" }).click(); @@ -101,6 +107,7 @@ test("proxy password drafts save once, reload safely, and authenticate offline", await page.reload(); await page.waitForSelector(COMPOSER_INPUT); + await ensureSidebarExpanded(page); await page.getByRole("button", { name: "设置" }).click(); await page.getByRole("button", { name: "通用", exact: true }).click(); const reloadedPassword = page.getByPlaceholder( @@ -125,6 +132,7 @@ test("proxy password drafts save once, reload safely, and authenticate offline", await page.reload(); await page.waitForSelector(COMPOSER_INPUT); + await ensureSidebarExpanded(page); await page.getByRole("button", { name: "设置" }).click(); await page.getByRole("button", { name: "通用", exact: true }).click(); await expect( From a0ea9464733a2678fdf565c26e29ff011f8e94a4 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:04:28 +0800 Subject: [PATCH 16/20] fix(desktop): bind restored credentials to targets --- .../__tests__/config-transfer-service.test.ts | 82 ++++++++++++++++++- .../runtime-host-config-ipc-main.test.ts | 59 ++++++++++++- .../src/main/config-transfer-service.ts | 40 +++++++-- .../src/main/runtime-host-config-ipc-main.ts | 32 +++++++- 4 files changed, 194 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index 3e89906f13..1cb9b9e8f7 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -24,7 +24,10 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { CredentialKind } from '@maka/storage/credential-store'; import { applyConfigImport, type ConfigTransferDeps } from '../config-transfer-service.js'; -function conn(slug: string): LlmConnection { +function conn( + slug: string, + overrides: Partial = {}, +): LlmConnection { return { slug, name: slug, @@ -33,6 +36,7 @@ function conn(slug: string): LlmConnection { enabled: true, createdAt: 1, updatedAt: 1, + ...overrides, }; } @@ -169,7 +173,17 @@ describe('config-transfer-service', () => { appVersion: '0.1.0', includedData: ['credentials'] as const, data: { - credentials: [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-restored' }], + credentials: [ + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-restored', + connection: { + providerType: 'deepseek', + effectiveBaseUrl: 'https://api.deepseek.com', + }, + }, + ], }, }; @@ -182,6 +196,70 @@ describe('config-transfer-service', () => { assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); }); + it('skips a credentials-only entry when the target slug belongs to another provider', async () => { + const { deps, setCreds } = makeDeps(); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['credentials'] as const, + data: { + credentials: [ + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-openai-source', + connection: { + providerType: 'openai', + effectiveBaseUrl: 'https://api.openai.com/v1', + }, + }, + ], + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(setCreds, []); + assert.deepEqual(result.credentials, { applied: 0, skipped: 1 }); + }); + + it('skips a credentials-only entry when the target endpoint differs', async () => { + const target = conn('deepseek-main', { + baseUrl: 'https://target-relay.example/v1', + }); + const { deps, setCreds } = makeDeps({ + connectionStore: { + list: async () => [target], + save: async (connection) => connection, + }, + }); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['credentials'] as const, + data: { + credentials: [ + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-source-endpoint', + connection: { + providerType: 'deepseek', + effectiveBaseUrl: 'https://api.deepseek.com', + }, + }, + ], + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(setCreds, []); + assert.deepEqual(result.credentials, { applied: 0, skipped: 1 }); + }); + it('restores the whole bundle when it carries a retained retired connection', async () => { // A backup taken before the retirement still lists the connection, and the // catalog refuses to create one. Before this was planned as skipped, the 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 d055e845ea..fac9628e2c 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 @@ -106,7 +106,15 @@ test('Runtime Host config export reads selected credentials from Host authority' const settings = bundle.data.settings as Record; assert.deepEqual(bundle.data.credentials, [ - { slug: 'deepseek-main', kind: 'api_key', value: 'sk-host' }, + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-host', + connection: { + providerType: 'deepseek', + effectiveBaseUrl: 'https://api.deepseek.com', + }, + }, ]); assert.equal(settings.network.proxy.password, 'proxy-host'); assert.equal(settings.webSearch.providers.tavily.apiKey, 'tavily-host'); @@ -139,14 +147,57 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi assert.deepEqual(bundle.includedData, ['settings', 'credentials']); assert.deepEqual(bundle.data.settings, { - network: { proxy: { password: 'proxy-host' } }, + network: { proxy: { authEnabled: true, password: 'proxy-host' } }, webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } }, }); assert.deepEqual(bundle.data.credentials, [ - { slug: 'deepseek-main', kind: 'api_key', value: 'sk-host' }, + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-host', + connection: { + providerType: 'deepseek', + effectiveBaseUrl: 'https://api.deepseek.com', + }, + }, ]); }); +test('Runtime Host credentials-only proxy export adapts onto default target policy', async () => { + const exported = await gatherRuntimeHostConfig( + ['credentials'], + { + client: { + loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => ({ + credential: + locator.scope === 'network_proxy' + ? { + locator, + secretBase64: Buffer.from('proxy-host').toString('base64'), + } + : null, + }), + }, + appVersion: '0.1.0', + getSettings: async () => createDefaultSettings(), + } as never, + ); + + const adapted = adaptRuntimeHostConfigImport(exported); + const importedProxy = (adapted.data.settings as Record).network.proxy; + const targetProxy = { + ...createDefaultSettings().network.proxy, + ...importedProxy, + }; + + assert.equal(targetProxy.authEnabled, true); + assert.deepEqual(targetProxy.credential, { + kind: 'replace', + secret: 'proxy-host', + }); +}); + test('Runtime Host credentials-only export omits each absent settings-carried secret', async () => { const cases = [ { @@ -158,7 +209,7 @@ test('Runtime Host credentials-only export omits each absent settings-carried se { presentScope: 'network_proxy', expected: { - network: { proxy: { password: 'proxy-host' } }, + network: { proxy: { authEnabled: true, password: 'proxy-host' } }, }, }, { diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index 74caed1692..925491895c 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -19,6 +19,7 @@ import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import { + effectiveBaseUrl, reconcileConnectionAfterEnabledModelsChange, type LlmConnection, } from '@maka/core/llm-connections'; @@ -38,6 +39,10 @@ export interface ExportedCredential { slug: string; kind: CredentialKind; value: string; + connection?: { + providerType: LlmConnection['providerType']; + effectiveBaseUrl: string; + }; } const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ @@ -73,9 +78,9 @@ export async function applyConfigImport( ): Promise { const result: ConfigImportResult = {}; // A connection snapshot limits credential writes to connections created or - // overwritten by this import. Credentials-only bundles instead bind to an - // already-existing logical connection with the same slug. - const credentialTargetSlugs = new Set(); + // overwritten by this import. Credentials-only bundles instead require an + // existing slug whose provider and effective endpoint match the export. + const credentialTargets = new Map(); if (Array.isArray(bundle.data.connections)) { const incoming = bundle.data.connections as LlmConnection[]; @@ -91,7 +96,7 @@ export async function applyConfigImport( ? reconcileConnectionAfterEnabledModelsChange(connection, connection.enabledModelIds) : null; await deps.connectionStore.save(selection ? { ...connection, ...selection } : connection); - credentialTargetSlugs.add(connection.slug); + credentialTargets.set(connection.slug, null); } result.connections = { created: plan.create.length, @@ -103,12 +108,13 @@ export async function applyConfigImport( Array.isArray(bundle.data.credentials) ) { // Without a connection snapshot, the credential slug names an existing - // logical connection directly. A bundle that does include connections - // still uses the create/overwrite set above so an explicit skip cannot - // overwrite the target's credential. + // connection, while its binding proves that the slug still names the same + // credential destination. A bundle that does include connections still + // uses the create/overwrite set above so an explicit skip cannot overwrite + // the target's credential. const existing = await deps.connectionStore.list(); for (const connection of existing) { - credentialTargetSlugs.add(connection.slug); + credentialTargets.set(connection.slug, connection); } } @@ -130,7 +136,12 @@ export async function applyConfigImport( if (!valid) continue; // Unknown targets and connections explicitly skipped by a connection // snapshot keep their existing stored secret untouched. - if (!credentialTargetSlugs.has(entry.slug)) { + const target = credentialTargets.get(entry.slug); + if (!credentialTargets.has(entry.slug)) { + skipped += 1; + continue; + } + if (target && !matchesCredentialConnection(entry.connection, target)) { skipped += 1; continue; } @@ -147,3 +158,14 @@ export async function applyConfigImport( return result; } + +function matchesCredentialConnection( + binding: ExportedCredential['connection'] | undefined, + target: LlmConnection, +): boolean { + return ( + binding !== undefined && + binding.providerType === target.providerType && + binding.effectiveBaseUrl === effectiveBaseUrl(target) + ); +} 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 fe30adf108..d20c87b347 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -21,7 +21,10 @@ import { readFile, writeFile } from 'node:fs/promises'; import type { IpcMain } from 'electron'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { + effectiveBaseUrl, + PROVIDER_DEFAULTS, +} from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry, CredentialLocator, @@ -373,14 +376,35 @@ function connectionCredentials( } as const; const requestHeaders = secrets.get(locatorKey(requestHeadersLocator)); return [ - ...(locator && secret ? [{ slug: connection.slug, kind: locator.kind, value: secret }] : []), + ...(locator && secret + ? [{ + slug: connection.slug, + kind: locator.kind, + value: secret, + connection: credentialConnectionBinding(connection), + }] + : []), ...(requestHeaders - ? [{ slug: connection.slug, kind: 'request_headers' as const, value: requestHeaders }] + ? [{ + slug: connection.slug, + kind: 'request_headers' as const, + value: requestHeaders, + connection: credentialConnectionBinding(connection), + }] : []), ]; }); } +function credentialConnectionBinding( + connection: ConnectionCatalogEntry, +): NonNullable { + return { + providerType: connection.providerType, + effectiveBaseUrl: effectiveBaseUrl(connection), + }; +} + function restoreHostSettingsSecrets( settings: AppSettings, secrets: ReadonlyMap, @@ -429,7 +453,7 @@ function projectHostSettingsSecrets( ? {} : { network: { - proxy: { password: proxy }, + proxy: { authEnabled: true, password: proxy }, }, }), ...(tavily === undefined From 6c6644b72d8a08e4775e27685085d441d67d6dad Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:49:23 +0800 Subject: [PATCH 17/20] fix(config): bind credential transfers to host targets --- .../__tests__/config-transfer-service.test.ts | 51 ++++- .../runtime-host-config-ipc-main.test.ts | 214 +++++++++++++++--- .../runtime-host-settings-ipc-main.test.ts | 42 ++++ .../src/main/config-transfer-service.ts | 59 +++-- .../src/main/runtime-host-config-ipc-main.ts | 181 +++++++++++---- .../main/runtime-host-settings-ipc-main.ts | 43 +++- packages/core/src/runtime-policy.ts | 37 ++- .../connection-catalog-codec.ts | 63 ++++++ .../runtime-policy/credential-vault-codec.ts | 16 +- .../core/src/runtime-policy/policy-codec.ts | 56 ++++- packages/core/src/settings.ts | 24 +- .../src/__tests__/protocol.test.ts | 127 ++++++++++- .../runtime-policy-coordinator.test.ts | 93 +++++++- .../src/protocol/configuration.ts | 91 +++++++- packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/runtime-policy.ts | 21 ++ .../src/server/configuration-coordinator.ts | 21 +- .../src/server/runtime-policy-coordinator.ts | 9 +- .../__tests__/runtime-policy-stores.test.ts | 155 +++++++++++++ packages/storage/src/runtime-policy-stores.ts | 11 +- .../storage/src/runtime-policy/coordinator.ts | 87 ++++++- .../storage/src/runtime-policy/operations.ts | 19 ++ 22 files changed, 1296 insertions(+), 128 deletions(-) diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index 1cb9b9e8f7..4810f5fc87 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -19,7 +19,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { AppSettings } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { CredentialKind } from '@maka/storage/credential-store'; import { applyConfigImport, type ConfigTransferDeps } from '../config-transfer-service.js'; @@ -62,12 +61,13 @@ function makeDeps(overrides: Partial = {}): { settingsStore: { update: async (patch) => { updatedSettings.push(patch); - return patch as unknown as AppSettings; + return { skippedCredentials: 0 }; }, }, credentialStore: { - setSecret: async (slug, kind, value) => { + setSecret: async ({ slug, kind, value }) => { setCreds.push({ slug, kind, value }); + return true; }, }, writeMemory: async (content) => { @@ -104,6 +104,28 @@ describe('config-transfer-service', () => { assert.deepEqual(writtenMemory, ['# imported memory']); }); + it('reports a settings-carried proxy credential skipped by Host target binding', async () => { + const { deps } = makeDeps({ + settingsStore: { + update: async () => ({ skippedCredentials: 1 }), + }, + } as never); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['settings', 'credentials'] as const, + data: { + settings: { network: { proxy: { credential: { kind: 'replace', secret: 'source' } } } }, + credentials: [], + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(result.credentials, { applied: 0, skipped: 1 }); + }); + it('restores the selection a backup states instead of re-enabling its default', async () => { // A backup can hold a connection whose default model the user had disabled. // `save()` cannot tell a stated selection from one a sync echoed back, so it @@ -165,6 +187,29 @@ describe('config-transfer-service', () => { assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); }); + it('reports a Host-bound connection credential write that loses its target race', async () => { + const { deps, setCreds } = makeDeps({ + credentialStore: { + setSecret: async () => false, + }, + } as never); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['connections', 'credentials'] as const, + data: { + connections: [conn('deepseek-main')], + credentials: [{ slug: 'deepseek-main', kind: 'api_key', value: 'source-secret' }], + }, + }; + + const result = await applyConfigImport(bundle as any, 'overwrite', deps); + + assert.deepEqual(setCreds, []); + assert.deepEqual(result.credentials, { applied: 0, skipped: 1 }); + }); + it('writes a credentials-only bundle to an existing connection', async () => { const { deps, saved, setCreds } = makeDeps(); const bundle = { 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 fac9628e2c..1eb066807b 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 @@ -92,10 +92,7 @@ test('Runtime Host config export reads selected credentials from Host authority' credential: secret === null ? null - : { - locator, - secretBase64: Buffer.from(secret).toString('base64'), - }, + : exportedCredential(locator, secret), }; }, }, @@ -112,7 +109,7 @@ test('Runtime Host config export reads selected credentials from Host authority' value: 'sk-host', connection: { providerType: 'deepseek', - effectiveBaseUrl: 'https://api.deepseek.com', + effectiveBaseUrl: 'https://api.deepseek.com/', }, }, ]); @@ -121,6 +118,80 @@ test('Runtime Host config export reads selected credentials from Host authority' assert.equal(settings.botChat.channels.telegram.token, 'bot-secret'); }); +test('Runtime Host config export retries when a bound connection target changes', async () => { + const movedCatalog: ConnectionCatalogSnapshot = { + ...CATALOG, + revision: 2, + connections: [ + { + ...CATALOG.connections[0]!, + revision: 2, + baseUrl: 'https://target-relay.example/v1', + }, + ], + }; + let catalogReads = 0; + + const bundle = await gatherRuntimeHostConfig( + ['credentials'], + { + client: { + loadConnectionCatalog: async () => { + catalogReads += 1; + return catalogReads === 1 ? CATALOG : movedCatalog; + }, + exportConfigurationCredentials: async ({ + locator, + expectedConnection, + }: { + locator: CredentialLocator; + expectedConnection?: { revision: number }; + }) => { + if (locator.scope !== 'connection') return { credential: null }; + if (expectedConnection?.revision !== 2) { + return { + credential: null, + connectionStale: { + expected: { + connectionId: CATALOG.connections[0]!.connectionId, + revision: 1, + }, + actual: { + connectionId: CATALOG.connections[0]!.connectionId, + revision: 2, + }, + }, + }; + } + return locator.kind === 'api_key' + ? { + credential: { + locator, + secretBase64: Buffer.from('freshly-bound-secret').toString('base64'), + }, + } + : { credential: null }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + assert.equal(catalogReads, 2); + assert.deepEqual(bundle.data.credentials, [ + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'freshly-bound-secret', + connection: { + providerType: 'deepseek', + effectiveBaseUrl: 'https://target-relay.example/v1', + }, + }, + ]); +}); + test('Runtime Host credentials-only export includes only schema-v1 credential fields', async () => { const bundle = await gatherRuntimeHostConfig( ['credentials'], @@ -133,10 +204,7 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi credential: secret === null ? null - : { - locator, - secretBase64: Buffer.from(secret).toString('base64'), - }, + : exportedCredential(locator, secret), }; }, }, @@ -147,7 +215,17 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi assert.deepEqual(bundle.includedData, ['settings', 'credentials']); assert.deepEqual(bundle.data.settings, { - network: { proxy: { authEnabled: true, password: 'proxy-host' } }, + network: { + proxy: { + password: 'proxy-host', + credentialTarget: { + protocol: 'http', + host: '127.0.0.1', + port: 7890, + username: '', + }, + }, + }, webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } }, }); assert.deepEqual(bundle.data.credentials, [ @@ -157,13 +235,13 @@ test('Runtime Host credentials-only export includes only schema-v1 credential fi value: 'sk-host', connection: { providerType: 'deepseek', - effectiveBaseUrl: 'https://api.deepseek.com', + effectiveBaseUrl: 'https://api.deepseek.com/', }, }, ]); }); -test('Runtime Host credentials-only proxy export adapts onto default target policy', async () => { +test('Runtime Host credentials-only proxy export carries a target binding without patching policy', async () => { const exported = await gatherRuntimeHostConfig( ['credentials'], { @@ -173,8 +251,7 @@ test('Runtime Host credentials-only proxy export adapts onto default target poli credential: locator.scope === 'network_proxy' ? { - locator, - secretBase64: Buffer.from('proxy-host').toString('base64'), + ...exportedCredential(locator, 'proxy-host'), } : null, }), @@ -186,18 +263,77 @@ test('Runtime Host credentials-only proxy export adapts onto default target poli const adapted = adaptRuntimeHostConfigImport(exported); const importedProxy = (adapted.data.settings as Record).network.proxy; - const targetProxy = { - ...createDefaultSettings().network.proxy, - ...importedProxy, - }; - assert.equal(targetProxy.authEnabled, true); - assert.deepEqual(targetProxy.credential, { - kind: 'replace', - secret: 'proxy-host', + assert.deepEqual(importedProxy, { + credential: { + kind: 'replace', + secret: 'proxy-host', + expectedTarget: { + protocol: 'http', + host: '127.0.0.1', + port: 7890, + username: '', + }, + }, }); }); +test('Runtime Host settings export retries when the proxy changes after its secret read', async () => { + let exports = 0; + let settingsReads = 0; + const source = settingsWithSecrets(); + source.network.proxy.enabled = true; + source.network.proxy.authEnabled = true; + source.network.proxy.host = 'proxy-a.example'; + source.network.proxy.port = 8080; + source.network.proxy.username = 'source-user'; + + const bundle = await gatherRuntimeHostConfig( + ['settings', 'credentials'], + { + client: { + loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }), + exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => { + if (locator.scope !== 'network_proxy') return { credential: null }; + exports += 1; + return { + credential: { + locator, + secretBase64: Buffer.from('proxy-a-secret').toString('base64'), + proxyTarget: { + protocol: 'http' as const, + host: 'proxy-a.example', + port: 8080, + username: 'source-user', + }, + }, + }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => { + settingsReads += 1; + return settingsReads === 1 + ? { + ...source, + network: { + proxy: { + ...source.network.proxy, + host: 'proxy-b.example', + }, + }, + } + : source; + }, + } as never, + ); + + assert.equal(exports, 2); + const settings = bundle.data.settings as Record; + assert.equal(settings.network.proxy.host, 'proxy-a.example'); + assert.equal(settings.network.proxy.password, 'proxy-a-secret'); +}); + test('Runtime Host credentials-only export omits each absent settings-carried secret', async () => { const cases = [ { @@ -209,7 +345,17 @@ test('Runtime Host credentials-only export omits each absent settings-carried se { presentScope: 'network_proxy', expected: { - network: { proxy: { authEnabled: true, password: 'proxy-host' } }, + network: { + proxy: { + password: 'proxy-host', + credentialTarget: { + protocol: 'http', + host: '127.0.0.1', + port: 7890, + username: '', + }, + }, + }, }, }, { @@ -234,10 +380,7 @@ test('Runtime Host credentials-only export omits each absent settings-carried se credential: secret === null ? null - : { - locator, - secretBase64: Buffer.from(secret).toString('base64'), - }, + : exportedCredential(locator, secret), }; }, }, @@ -376,3 +519,20 @@ function secretFor(locator: CredentialLocator): string | null { } return null; } + +function exportedCredential(locator: CredentialLocator, secret: string) { + return { + locator, + secretBase64: Buffer.from(secret).toString('base64'), + ...(locator.scope === 'network_proxy' + ? { + proxyTarget: { + protocol: 'http' as const, + host: '127.0.0.1', + port: 7890, + username: '', + }, + } + : {}), + }; +} 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 fc8ec1fa4f..63bd715226 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 @@ -112,6 +112,7 @@ function createModuleFixture(options: { configured?: boolean; beforeSetCredential?: () => Promise; failFirstSet?: boolean; + proxyTargetMismatch?: boolean; } = {}) { let policy = createDefaultRuntimePolicy(); if (options.configured) { @@ -152,6 +153,18 @@ function createModuleFixture(options: { actualRevision: policyRevision, }; } + if (options.proxyTargetMismatch && input.credential.kind === "replace") { + return { + kind: "proxy_target_mismatch" as const, + expected: input.credential.expectedTarget!, + actual: { + protocol: "http" as const, + host: "proxy-b.example", + port: 8080, + username: "target-user", + }, + }; + } if (input.credential.kind === "replace") { events.push(`set:${input.credential.secret}`); await options.beforeSetCredential?.(); @@ -333,6 +346,35 @@ test("keep, replace, and explicit delete preserve the derived credential contrac assert.equal(deleted.network.proxy.passwordConfigured, false); }); +test("config import skips a proxy password whose bound target no longer matches", async () => { + const fixture = createModuleFixture({ configured: true, proxyTargetMismatch: true }); + + const imported = await runRuntimeHostSettingsExclusive( + fixture.module, + (settings) => + settings.updateForConfigImport({ + network: { + proxy: { + credential: { + kind: "replace", + secret: "source-import-secret", + expectedTarget: { + protocol: "http", + host: "proxy-a.example", + port: 8080, + username: "source-user", + }, + }, + }, + }, + }), + ); + + assert.equal(imported.skippedCredentials, 1); + assert.equal(fixture.secret(), "saved-secret"); + assert.deepEqual(fixture.events, []); +}); + test("a later authentication disable waits for an in-flight replacement and wins", async () => { let release!: () => void; const blocked = new Promise((resolve) => { diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index 925491895c..fe632cafbb 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -17,12 +17,12 @@ * under the License. */ -import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; +import type { UpdateAppSettingsInput } from '@maka/core/settings'; import { - effectiveBaseUrl, reconcileConnectionAfterEnabledModelsChange, type LlmConnection, } from '@maka/core/llm-connections'; +import { canonicalConnectionEffectiveBaseUrl } from '@maka/core/runtime-policy'; import { type ConfigBundle, type ConnectionConflictStrategy, @@ -57,9 +57,11 @@ const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ export interface ConfigTransferDeps { connectionStore: { list(): Promise; save(c: LlmConnection): Promise }; - settingsStore: { update(patch: UpdateAppSettingsInput): Promise }; + settingsStore: { + update(patch: UpdateAppSettingsInput): Promise<{ skippedCredentials: number }>; + }; credentialStore: { - setSecret(slug: string, kind: CredentialKind, value: string): Promise; + setSecret(entry: ExportedCredential): Promise; }; writeMemory(content: string): Promise; } @@ -80,7 +82,7 @@ export async function applyConfigImport( // A connection snapshot limits credential writes to connections created or // overwritten by this import. Credentials-only bundles instead require an // existing slug whose provider and effective endpoint match the export. - const credentialTargets = new Map(); + const credentialTargets = new Map(); if (Array.isArray(bundle.data.connections)) { const incoming = bundle.data.connections as LlmConnection[]; @@ -96,7 +98,7 @@ export async function applyConfigImport( ? reconcileConnectionAfterEnabledModelsChange(connection, connection.enabledModelIds) : null; await deps.connectionStore.save(selection ? { ...connection, ...selection } : connection); - credentialTargets.set(connection.slug, null); + credentialTargets.set(connection.slug, connection); } result.connections = { created: plan.create.length, @@ -118,14 +120,18 @@ export async function applyConfigImport( } } + let settingsCredentialSkips = 0; if (bundle.data.settings && typeof bundle.data.settings === 'object') { - await deps.settingsStore.update(bundle.data.settings as unknown as UpdateAppSettingsInput); + const applied = await deps.settingsStore.update( + bundle.data.settings as unknown as UpdateAppSettingsInput, + ); + settingsCredentialSkips = applied.skippedCredentials; result.settings = { applied: true }; } if (Array.isArray(bundle.data.credentials)) { let applied = 0; - let skipped = 0; + let skipped = settingsCredentialSkips; for (const entry of bundle.data.credentials as ExportedCredential[]) { const valid = entry && @@ -137,18 +143,24 @@ export async function applyConfigImport( // Unknown targets and connections explicitly skipped by a connection // snapshot keep their existing stored secret untouched. const target = credentialTargets.get(entry.slug); - if (!credentialTargets.has(entry.slug)) { + if (!target) { skipped += 1; continue; } - if (target && !matchesCredentialConnection(entry.connection, target)) { + const binding = entry.connection ?? credentialConnectionBinding(target); + if (!matchesCredentialConnection(binding, target)) { skipped += 1; continue; } - await deps.credentialStore.setSecret(entry.slug, entry.kind, entry.value); - applied += 1; + if (await deps.credentialStore.setSecret({ ...entry, connection: binding })) { + applied += 1; + } else { + skipped += 1; + } } result.credentials = { applied, skipped }; + } else if (settingsCredentialSkips > 0) { + result.credentials = { applied: 0, skipped: settingsCredentialSkips }; } if (typeof bundle.data.memory === 'string') { @@ -159,13 +171,30 @@ export async function applyConfigImport( return result; } -function matchesCredentialConnection( +export function matchesCredentialConnection( binding: ExportedCredential['connection'] | undefined, - target: LlmConnection, + target: Pick, ): boolean { return ( binding !== undefined && binding.providerType === target.providerType && - binding.effectiveBaseUrl === effectiveBaseUrl(target) + canonicalEndpoint(binding.effectiveBaseUrl) === canonicalConnectionEffectiveBaseUrl(target) ); } + +function credentialConnectionBinding( + connection: LlmConnection, +): NonNullable { + return { + providerType: connection.providerType, + effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection), + }; +} + +function canonicalEndpoint(value: string): string | null { + try { + return new URL(value).toString(); + } catch { + return null; + } +} 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 d20c87b347..7560050c65 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -18,19 +18,24 @@ */ import { readFile, writeFile } from 'node:fs/promises'; +import { isDeepStrictEqual } from 'node:util'; import type { IpcMain } from 'electron'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { - effectiveBaseUrl, - PROVIDER_DEFAULTS, -} from '@maka/core/llm-connections'; -import type { - ConnectionCatalogEntry, - CredentialLocator, + canonicalConnectionEffectiveBaseUrl, + connectionCredentialTarget, + type ConnectionCatalogEntry, + type ConnectionCredentialTarget, + type CredentialLocator, + type NetworkProxyCredentialTarget, + normalizeNetworkProxyCredentialTarget, } from '@maka/core/runtime-policy'; +import { networkProxyCredentialTarget } from '@maka/core/settings'; import { applyConfigImport, + matchesCredentialConnection, type ConfigTransferDeps, type ExportedCredential, } from './config-transfer-service.js'; @@ -78,9 +83,9 @@ interface RuntimeHostConfigGatherDeps { interface RuntimeHostConfigTransferDeps { readonly client: DesktopRuntimeHostClient; - readonly updateSettings: ( + readonly updateSettingsForConfigImport: ( patch: UpdateAppSettingsInput, - ) => Promise; + ) => Promise<{ skippedCredentials: number }>; } export function registerRuntimeHostConfigIpc( @@ -157,7 +162,10 @@ export function registerRuntimeHostConfigIpc( importBundle, sanitizeStrategy(input?.strategy), runtimeHostTransferDeps( - { client: deps.client, updateSettings: settings.update }, + { + client: deps.client, + updateSettingsForConfigImport: settings.updateForConfigImport, + }, ), ), ); @@ -177,14 +185,47 @@ export async function gatherRuntimeHostConfig( ) { const selected = new Set(categories); const data: ConfigData = {}; - const catalog = - selected.has('connections') || selected.has('credentials') - ? await deps.client.loadConnectionCatalog() - : undefined; - const locators = selected.has('credentials') - ? exportLocators(catalog?.connections ?? []) - : []; - const exported = await exportConfigurationCredentials(deps.client, locators); + let catalog = selected.has('connections') && !selected.has('credentials') + ? await deps.client.loadConnectionCatalog() + : undefined; + let exported: Awaited> = { + credentials: [], + connectionStale: false, + proxyTarget: undefined, + }; + let settings: AppSettings | undefined; + if (selected.has('credentials')) { + for (let attempt = 0; attempt < 3; attempt += 1) { + catalog = await deps.client.loadConnectionCatalog(); + exported = await exportConfigurationCredentials( + deps.client, + exportLocators(catalog.connections), + ); + if (exported.connectionStale) { + if (attempt === 2) { + throw new Error('Connection targets kept changing while credentials were exported'); + } + continue; + } + if ( + selected.has('settings') && + exported.proxyTarget && + !isDeepStrictEqual( + exported.proxyTarget, + networkProxyCredentialTarget((settings = await deps.getSettings()).network.proxy), + ) + ) { + if (attempt === 2) { + throw new Error('Proxy target kept changing while credentials were exported'); + } + continue; + } + if (selected.has('settings') && !settings) settings = await deps.getSettings(); + break; + } + } else if (selected.has('settings')) { + settings = await deps.getSettings(); + } const secrets = new Map( exported.credentials.map((entry) => [locatorKey(entry.locator), entry.secret]), ); @@ -196,12 +237,12 @@ export async function gatherRuntimeHostConfig( // settings payload. Keep a credentials-only request lossless by making the // dependency explicit in the generated bundle. if (selected.has('settings')) { - const settings = await deps.getSettings(); + if (!settings) throw new Error('Settings snapshot was not gathered'); data.settings = selected.has('credentials') ? restoreHostSettingsSecrets(settings, secrets) : stripSettingsSecretsForExport(settings); } else if (selected.has('credentials')) { - const settingsSecrets = projectHostSettingsSecrets(secrets); + const settingsSecrets = projectHostSettingsSecrets(exported.proxyTarget, secrets); if (settingsSecrets) data.settings = settingsSecrets; } if (selected.has('credentials') && catalog) { @@ -215,19 +256,44 @@ export async function gatherRuntimeHostConfig( async function exportConfigurationCredentials( client: DesktopRuntimeHostClient, - locators: readonly CredentialLocator[], + requests: readonly CredentialExportRequest[], ) { - const credentials: Array<{ locator: CredentialLocator; secret: string }> = []; - for (const locator of locators) { - const exported = await client.exportConfigurationCredentials({ locator }); + const credentials: Array<{ + locator: CredentialLocator; + secret: string; + proxyTarget?: NetworkProxyCredentialTarget; + }> = []; + let proxyTarget: NetworkProxyCredentialTarget | undefined; + for (const request of requests) { + const exported = await client.exportConfigurationCredentials(request); + if (exported.connectionStale) { + return { credentials: [], connectionStale: true, proxyTarget: undefined }; + } if (exported.credential) { + if ( + exported.credential.locator.scope === 'network_proxy' && + !exported.credential.proxyTarget + ) { + throw new Error('Runtime Host omitted the proxy credential target binding'); + } + if (exported.credential.proxyTarget) { + proxyTarget = exported.credential.proxyTarget; + } credentials.push({ locator: exported.credential.locator, secret: Buffer.from(exported.credential.secretBase64, 'base64').toString('utf8'), + ...(exported.credential.proxyTarget === undefined + ? {} + : { proxyTarget: exported.credential.proxyTarget }), }); } } - return { credentials }; + return { credentials, connectionStale: false, proxyTarget }; +} + +interface CredentialExportRequest { + readonly locator: CredentialLocator; + readonly expectedConnection?: ConnectionCredentialTarget; } function runtimeHostTransferDeps( @@ -240,11 +306,13 @@ function runtimeHostTransferDeps( save: (connection) => saveConnection(deps.client, connection), }, settingsStore: { - update: deps.updateSettings, + update: async (patch) => { + const result = await deps.updateSettingsForConfigImport(patch); + return { skippedCredentials: result.skippedCredentials }; + }, }, credentialStore: { - setSecret: (slug, kind, value) => - saveConnectionCredential(deps.client, slug, kind, value), + setSecret: (entry) => saveConnectionCredential(deps.client, entry), }, writeMemory: (content) => replaceRuntimeHostMemoryDocument(deps.client, content), @@ -315,38 +383,35 @@ export async function saveConnection( async function saveConnectionCredential( client: DesktopRuntimeHostClient, - slug: string, - kind: string, - value: string, -): Promise { + entry: ExportedCredential, +): Promise { const catalog = await client.loadConnectionCatalog(); - const connection = catalog.connections.find((item) => item.slug === slug); - if (!connection) throw new Error(`Imported Connection not found: ${slug}`); + const connection = catalog.connections.find((item) => item.slug === entry.slug); + if (!connection || !matchesCredentialConnection(entry.connection, connection)) return false; const locator = - kind === 'request_headers' + entry.kind === 'request_headers' ? ({ scope: 'connection', connectionId: connection.connectionId, kind: 'request_headers', } as const) : connectionCredentialLocator(connection); - if (!locator || locator.kind !== kind) return; + if (!locator || locator.kind !== entry.kind) return false; const current = await client.queryCredential(locator); const saved = await client.setCredential({ locator, expected: current?.configured ? { credentialId: current.credentialId, revision: current.revision } : null, - secret: value, + expectedConnection: connectionCredentialTarget(connection), + secret: entry.value, }); - if (saved.kind !== 'committed') { - throw new Error(`Unable to save imported Connection credential: ${saved.kind}`); - } + return saved.kind === 'committed'; } function exportLocators( connections: readonly ConnectionCatalogEntry[], -): CredentialLocator[] { +): CredentialExportRequest[] { return [ ...connections.flatMap((connection) => { const locator = connectionCredentialLocator(connection); @@ -355,10 +420,16 @@ function exportLocators( connectionId: connection.connectionId, kind: 'request_headers', } as const; - return locator ? [locator, requestHeaders] : [requestHeaders]; + const expectedConnection = connectionCredentialTarget(connection); + return (locator ? [locator, requestHeaders] : [requestHeaders]).map( + (connectionLocator) => ({ + locator: connectionLocator, + expectedConnection, + }), + ); }), - { scope: 'network_proxy', kind: 'password' }, - { scope: 'web_search', provider: 'tavily', kind: 'api_key' }, + { locator: { scope: 'network_proxy', kind: 'password' } }, + { locator: { scope: 'web_search', provider: 'tavily', kind: 'api_key' } }, ]; } @@ -401,7 +472,7 @@ function credentialConnectionBinding( ): NonNullable { return { providerType: connection.providerType, - effectiveBaseUrl: effectiveBaseUrl(connection), + effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection), }; } @@ -438,6 +509,7 @@ function restoreHostSettingsSecrets( } function projectHostSettingsSecrets( + proxyTarget: NetworkProxyCredentialTarget | undefined, secrets: ReadonlyMap, ): Record | undefined { const proxy = secrets.get( @@ -449,11 +521,14 @@ function projectHostSettingsSecrets( if (proxy === undefined && tavily === undefined) return undefined; return { - ...(proxy === undefined + ...(proxy === undefined || proxyTarget === undefined ? {} : { network: { - proxy: { authEnabled: true, password: proxy }, + proxy: { + password: proxy, + credentialTarget: proxyTarget, + }, }, }), ...(tavily === undefined @@ -476,6 +551,7 @@ export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle if (!isRecord(network) || !isRecord(network.proxy)) return bundle; const wireProxy = network.proxy; + const credentialTarget = wireProxy.credentialTarget; const passwordPresent = Object.prototype.hasOwnProperty.call( wireProxy, 'password', @@ -504,6 +580,7 @@ export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle password: _password, passwordConfigured: _passwordConfigured, credential: _credential, + credentialTarget: _credentialTarget, ...ordinaryProxy } = wireProxy; const proxy = { @@ -513,7 +590,17 @@ export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle credential: (password as string).length === 0 ? ({ kind: 'delete' } as const) - : ({ kind: 'replace', secret: password as string } as const), + : ({ + kind: 'replace', + secret: password as string, + ...(credentialTarget === undefined + ? {} + : { + expectedTarget: normalizeNetworkProxyCredentialTarget( + credentialTarget, + ), + }), + } as const), } : {}), }; 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 36128b0504..369ec810e0 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -92,6 +92,14 @@ export interface RuntimeHostSettingsModule { export interface RuntimeHostSettingsExclusiveAccess { get(): Promise; update(patch: UpdateAppSettingsInput): Promise; + updateForConfigImport( + patch: UpdateAppSettingsInput, + ): Promise; +} + +export interface RuntimeHostSettingsImportResult { + readonly settings: RuntimeHostAppSettings; + readonly skippedCredentials: number; } type RuntimeHostSettingsExclusiveRunner = ( @@ -125,7 +133,11 @@ export function createRuntimeHostSettingsModule( const module: RuntimeHostSettingsModule = { get: () => enqueue(() => loadRuntimeHostSettingsWithoutLane(deps)), update: (patch) => - enqueue(() => updateRuntimeHostSettingsWithoutLane(deps, patch)), + enqueue(() => + updateRuntimeHostSettingsForImportWithoutLane(deps, patch).then( + (result) => result.settings, + ), + ), testNetworkProxy: (input = {}) => enqueue(() => testNetworkProxyWithoutLane(deps.client, input)), }; @@ -133,7 +145,12 @@ export function createRuntimeHostSettingsModule( enqueue(() => operation({ get: () => loadRuntimeHostSettingsWithoutLane(deps), - update: (patch) => updateRuntimeHostSettingsWithoutLane(deps, patch), + update: (patch) => + updateRuntimeHostSettingsForImportWithoutLane(deps, patch).then( + (result) => result.settings, + ), + updateForConfigImport: (patch) => + updateRuntimeHostSettingsForImportWithoutLane(deps, patch), }), ), ); @@ -277,18 +294,21 @@ async function loadRuntimeHostSettingsWithoutLane( }; } -async function updateRuntimeHostSettingsWithoutLane( +async function updateRuntimeHostSettingsForImportWithoutLane( deps: RuntimeHostSettingsModuleDeps, patch: UpdateAppSettingsInput, -): Promise { +): Promise { validateProxyPatch(patch.network?.proxy); - await applyHostPatchWithoutLane(deps.client, patch); + const skippedCredentials = 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); + return { + settings: await loadRuntimeHostSettingsWithoutLane(deps), + skippedCredentials, + }; } function projectWebSearchCredential( @@ -316,9 +336,10 @@ function projectWebSearchCredential( async function applyHostPatchWithoutLane( client: RuntimeHostSettingsClient, patch: UpdateAppSettingsInput, -): Promise { +): Promise { + let skippedCredentials = 0; if (patch.network?.proxy) { - await updateNetworkProxy(client, patch.network.proxy); + skippedCredentials += await updateNetworkProxy(client, patch.network.proxy); } if ( patch.personalization?.displayName !== undefined || @@ -389,12 +410,13 @@ async function applyHostPatchWithoutLane( value: patch.subagents!, })); } + return skippedCredentials; } async function updateNetworkProxy( client: RuntimeHostSettingsClient, patch: NonNullable["proxy"]>, -): Promise { +): Promise { const [policy, credential] = await Promise.all([ client.queryRuntimePolicy(), client.queryCredential(PROXY_CREDENTIAL), @@ -421,7 +443,8 @@ async function updateNetworkProxy( networkProxy, credential: operation, }); - if (result.kind === "committed") return; + if (result.kind === "committed") return 0; + if (result.kind === "proxy_target_mismatch") return 1; if (result.kind === "revision_conflict") { throw new Error("Runtime Host proxy policy changed while Desktop updated it"); } diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index a8c2560d39..c5b24fb635 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -26,7 +26,13 @@ import type { import type { ThinkingLevel } from './model-thinking.js'; import type { ProviderType } from './provider-registry.js'; import type { RelayModelProfile } from './model-thinking.js'; -import type { ChatDefaultPermissionMode, ProxyProtocol, ShellSettings } from './settings.js'; +import { + networkProxyCredentialTarget, + type ChatDefaultPermissionMode, + type NetworkProxyCredentialTarget, + type ProxyProtocol, + type ShellSettings, +} from './settings.js'; import type { SubagentSettings } from './subagent-settings.js'; import type { JsonObject } from './request-customization.js'; import { @@ -36,6 +42,8 @@ import { } from './web-search.js'; export { WEB_SEARCH_PROVIDERS }; +export { networkProxyCredentialTarget }; +export type { NetworkProxyCredentialTarget }; export type { ConnectionTestErrorClass, ModelDiscoverySource } from './llm-connections.js'; export { decodeRuntimePolicyEntityId, @@ -43,6 +51,7 @@ export { } from './runtime-policy/domain-codec.js'; export { decodeCanonicalRuntimePolicy, + normalizeNetworkProxyCredentialTarget, decodeRuntimePolicyV2, normalizeNetworkProxyUpdate, normalizeRuntimePolicyMutation, @@ -56,6 +65,7 @@ export { decodeCanonicalConnectionBaseUrl, decodeCanonicalConnectionCatalogEntry, decodeConnectionModelId, + decodeConnectionCredentialTarget, decodeRelayModelProfilesTable, decodeConnectionModel, decodeConnectionName, @@ -69,6 +79,8 @@ export { normalizeConnectionCatalogEntryUpdate, normalizeConnectionCatalogEntryUpdateForProvider, normalizeConnectionModelDiscoveryResult, + canonicalConnectionEffectiveBaseUrl, + connectionCredentialTarget, normalizeCreateCatalogConnectionInput, normalizeRemoveCatalogConnectionInput, normalizeSetDefaultConnectionTargetInput, @@ -187,7 +199,11 @@ export type MutateRuntimePolicyResult = export type NetworkProxyCredentialUpdate = | { readonly kind: 'keep' } - | { readonly kind: 'replace'; readonly secret: string } + | { + readonly kind: 'replace'; + readonly secret: string; + readonly expectedTarget?: NetworkProxyCredentialTarget; + } | { readonly kind: 'delete' }; /** @@ -208,6 +224,11 @@ export type UpdateNetworkProxyResult = readonly credentialStatus: CredentialStatus; } | RevisionConflict + | { + readonly kind: 'proxy_target_mismatch'; + readonly expected: NetworkProxyCredentialTarget; + readonly actual: NetworkProxyCredentialTarget; + } | { readonly kind: 'credential_stale'; readonly expected: CredentialVersionBasis | null; @@ -310,6 +331,12 @@ export interface ConnectionVersionBasis { readonly revision: Revision; } +export interface ConnectionCredentialTarget extends ConnectionVersionBasis { + readonly slug: string; + readonly providerType: ProviderType; + readonly effectiveBaseUrl: string; +} + export interface ConnectionTarget { readonly connectionId: EntityId; readonly modelId: string; @@ -416,6 +443,7 @@ export interface CredentialVaultSnapshot { export interface SetCredentialInput { readonly locator: CredentialLocator; readonly expected: (CredentialIdentity & { readonly revision: Revision }) | null; + readonly expectedConnection?: ConnectionCredentialTarget; readonly secret: string; } @@ -426,6 +454,11 @@ export interface DeleteCredentialInput { export type CredentialMutationResult = | { readonly kind: 'committed'; readonly snapshot: CredentialVaultSnapshot } | { readonly kind: 'connection_not_found' } + | { + readonly kind: 'connection_stale'; + readonly expected: ConnectionVersionBasis; + readonly actual: ConnectionVersionBasis | null; + } | { readonly kind: 'credential_stale'; readonly expected: CredentialVersionBasis | null; diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index d90639cee5..84874dd2a8 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -19,6 +19,7 @@ import { isRelayProviderType, + effectiveBaseUrl, PROVIDER_DEFAULTS, providerDefaultsOf, validateSlug, @@ -32,6 +33,7 @@ import { } from '../model-thinking.js'; import type { ConnectionCatalogEntry, + ConnectionCredentialTarget, ConnectionCatalogEntryDraft, ConnectionCatalogEntryUpdate, ConnectionModel, @@ -453,6 +455,67 @@ export function decodeConnectionVersionBasis(value: unknown): ConnectionVersionB }; } +export function canonicalConnectionEffectiveBaseUrl( + connection: Pick, +): string { + const endpoint = effectiveBaseUrl(connection); + try { + return new URL(endpoint).toString(); + } catch { + throw domainError('connection has an invalid effective base URL'); + } +} + +export function connectionCredentialTarget( + connection: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'revision' | 'slug' | 'providerType' | 'baseUrl' + >, +): ConnectionCredentialTarget { + return { + connectionId: connection.connectionId, + revision: connection.revision, + slug: connection.slug, + providerType: connection.providerType, + effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection), + }; +} + +export function decodeConnectionCredentialTarget(value: unknown): ConnectionCredentialTarget { + const item = exactRecord(value, 'connection credential target', [ + 'connectionId', + 'revision', + 'slug', + 'providerType', + 'effectiveBaseUrl', + ]); + const version = decodeConnectionVersionBasis({ + connectionId: item.connectionId, + revision: item.revision, + }); + const providerType = decodeProviderType(item.providerType); + const effectiveBaseUrl = stringValue( + item.effectiveBaseUrl, + 'connection credential target effective base URL', + 2_048, + ); + let canonical: string; + try { + canonical = new URL(effectiveBaseUrl).toString(); + } catch { + throw domainError('connection credential target effective base URL must be valid'); + } + if (canonical !== effectiveBaseUrl) { + throw domainError('connection credential target effective base URL must be canonical'); + } + return { + ...version, + slug: decodeConnectionSlug(item.slug), + providerType, + effectiveBaseUrl, + }; +} + export function decodeConnectionTarget(value: unknown): ConnectionTarget { const item = exactRecord(value, 'connection target', ['connectionId', 'modelId']); return { diff --git a/packages/core/src/runtime-policy/credential-vault-codec.ts b/packages/core/src/runtime-policy/credential-vault-codec.ts index e709baf3b5..28cd1a232d 100644 --- a/packages/core/src/runtime-policy/credential-vault-codec.ts +++ b/packages/core/src/runtime-policy/credential-vault-codec.ts @@ -24,6 +24,7 @@ import type { DeleteCredentialInput, SetCredentialInput, } from '../runtime-policy.js'; +import { decodeConnectionCredentialTarget } from './connection-catalog-codec.js'; import { WEB_SEARCH_CREDENTIAL_PROVIDERS } from '../web-search.js'; import { parseRequestHeaders, @@ -124,7 +125,12 @@ export function decodeCredentialStatus(value: unknown): CredentialStatus { } export function normalizeSetCredentialInput(value: unknown): SetCredentialInput { - const input = exactRecord(value, 'set credential input', ['locator', 'expected', 'secret']); + const input = exactRecord( + value, + 'set credential input', + ['locator', 'expected', 'expectedConnection', 'secret'], + ['locator', 'expected', 'secret'], + ); let expected: SetCredentialInput['expected']; if (input.expected === null) { expected = null; @@ -139,10 +145,18 @@ export function normalizeSetCredentialInput(value: unknown): SetCredentialInput }; } const locator = decodeCredentialLocator(input.locator); + const expectedConnection = + input.expectedConnection === undefined + ? undefined + : decodeConnectionCredentialTarget(input.expectedConnection); + if (expectedConnection && locator.scope !== 'connection') { + throw domainError('only connection credentials accept a connection target basis'); + } const secret = normalizeCredentialSecret(input.secret); return { locator, expected, + ...(expectedConnection === undefined ? {} : { expectedConnection }), secret: locator.scope === 'connection' && locator.kind === 'request_headers' ? normalizeRequestHeadersSecret(secret) diff --git a/packages/core/src/runtime-policy/policy-codec.ts b/packages/core/src/runtime-policy/policy-codec.ts index b0f5a0997d..3462dbc01b 100644 --- a/packages/core/src/runtime-policy/policy-codec.ts +++ b/packages/core/src/runtime-policy/policy-codec.ts @@ -24,6 +24,7 @@ import type { AgentRuntimeSettingsPatch, MutateRuntimePolicyInput, NetworkProxyCredentialUpdate, + NetworkProxyCredentialTarget, RuntimePolicy, RuntimePolicyMutation, UpdateNetworkProxyInput, @@ -111,7 +112,12 @@ export function normalizeNetworkProxyUpdate(value: unknown): UpdateNetworkProxyI } function normalizeNetworkProxyCredentialUpdate(value: unknown): NetworkProxyCredentialUpdate { - const base = exactRecord(value, 'network proxy credential update', ['kind', 'secret'], ['kind']); + const base = exactRecord( + value, + 'network proxy credential update', + ['kind', 'secret', 'expectedTarget'], + ['kind'], + ); switch (base.kind) { case 'keep': case 'delete': { @@ -119,17 +125,55 @@ function normalizeNetworkProxyCredentialUpdate(value: unknown): NetworkProxyCred return { kind: base.kind }; } case 'replace': { - const replacement = exactRecord(value, 'network proxy credential replacement', [ - 'kind', - 'secret', - ]); - return { kind: 'replace', secret: normalizeCredentialSecret(replacement.secret) }; + const replacement = exactRecord( + value, + 'network proxy credential replacement', + ['kind', 'secret', 'expectedTarget'], + ['kind', 'secret'], + ); + return { + kind: 'replace', + secret: normalizeCredentialSecret(replacement.secret), + ...(replacement.expectedTarget === undefined + ? {} + : { + expectedTarget: normalizeNetworkProxyCredentialTarget(replacement.expectedTarget), + }), + }; } default: throw domainError(`network proxy credential update '${String(base.kind)}' is unknown`); } } +export function normalizeNetworkProxyCredentialTarget( + value: unknown, +): NetworkProxyCredentialTarget { + const item = exactRecord(value, 'network proxy credential target', [ + 'protocol', + 'host', + 'port', + 'username', + ]); + if (item.protocol !== 'http' && item.protocol !== 'https' && item.protocol !== 'socks5') { + throw domainError('network proxy credential target protocol is invalid'); + } + const rawHost = stringValue(item.host, 'network proxy credential target host', 255); + if (/[\u0000-\u001f\u007f-\u009f]/.test(rawHost)) { + throw domainError('network proxy credential target host must not contain control characters'); + } + const host = rawHost.trim().toLowerCase(); + if (host.length === 0) { + throw domainError('network proxy credential target host must not be empty'); + } + return { + protocol: item.protocol, + host, + port: integerValue(item.port, 'network proxy credential target port', 1, 65_535), + username: stringValue(item.username, 'network proxy credential target username', 256), + }; +} + function normalizeRuntimePolicy(value: unknown): RuntimePolicy { const policy = exactRecord(value, 'runtime policy', [ 'networkProxy', diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index b344359da4..b6d2daccec 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -103,8 +103,30 @@ export interface NetworkProxySettings { autoBypassDomains: string[]; } +export interface NetworkProxyCredentialTarget { + readonly protocol: ProxyProtocol; + readonly host: string; + readonly port: number; + readonly username: string; +} + +export function networkProxyCredentialTarget( + proxy: Pick, +): NetworkProxyCredentialTarget { + return { + protocol: proxy.protocol, + host: proxy.host.trim().toLowerCase(), + port: proxy.port, + username: proxy.username, + }; +} + export type NetworkProxyCredentialOperation = - | { kind: 'replace'; secret: string } + | { + kind: 'replace'; + secret: string; + expectedTarget?: NetworkProxyCredentialTarget; + } | { kind: 'delete' }; /** A write-only proxy patch. Credential operations are never persisted. */ diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 57726dc024..cc57a5e854 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -249,6 +249,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 66); }); + test('publishes a new compatibility epoch for bound configuration credentials', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 79); + }); + test('publishes a new compatibility epoch for the removed execution.inspect.resolve operation', () => { // Epoch 63 peers still know execution.inspect.resolve and would send it // only to fail mid-connection now that it is gone, so its removal must @@ -887,6 +891,96 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('keeps connection credential transfer bound to an exact Host target', () => { + const locator = { + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'api_key', + } as const; + const expectedConnection = { + connectionId: locator.connectionId, + revision: 4, + slug: 'deepseek-main', + providerType: 'deepseek' as const, + effectiveBaseUrl: 'https://api.deepseek.com/', + }; + const setInput = { + locator, + expected: null, + expectedConnection, + secret: 'bound-secret', + }; + assert.deepEqual( + RUNTIME_POLICY_OPERATION_SPECS['credential.vault.set'].decodeInput(setInput), + setInput, + ); + const exportInput = { locator, expectedConnection }; + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeInput(exportInput), + exportInput, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput({ + credential: null, + connectionStale: { + expected: { connectionId: locator.connectionId, revision: 4 }, + actual: { connectionId: locator.connectionId, revision: 5 }, + }, + }), + { + credential: null, + connectionStale: { + expected: { connectionId: locator.connectionId, revision: 4 }, + actual: { connectionId: locator.connectionId, revision: 5 }, + }, + }, + ); + assert.throws( + () => + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeInput({ + locator: { scope: 'network_proxy', kind: 'password' }, + expectedConnection, + }), + isInvalidFrame, + ); + }); + + test('exports a proxy credential with its Host-read target binding', () => { + const locator = { scope: 'network_proxy', kind: 'password' } as const; + const result = { + credential: { + locator, + secretBase64: Buffer.from('proxy-secret').toString('base64'), + proxyTarget: { + protocol: 'https' as const, + host: 'proxy.example', + port: 8443, + username: 'proxy-user', + }, + }, + }; + + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput(result), + result, + ); + assert.throws( + () => + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput({ + credential: { + locator: { + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'api_key', + }, + secretBase64: Buffer.from('connection-secret').toString('base64'), + proxyTarget: result.credential.proxyTarget, + }, + }), + isInvalidFrame, + ); + }); + test('keeps the connection update model limit aligned with the catalog', () => { const updateConnection = RUNTIME_POLICY_OPERATION_SPECS['connection.catalog.update']; const enabledModelIds = Array.from( @@ -995,7 +1089,16 @@ describe('Runtime Host bootstrap protocol', () => { bypassList: ['localhost'], autoBypassDomains: ['127.0.0.1'], }, - credential: { kind: 'replace' as const, secret: 'write-only-secret' }, + credential: { + kind: 'replace' as const, + secret: 'write-only-secret', + expectedTarget: { + protocol: 'http' as const, + host: '127.0.0.1', + port: 7897, + username: 'proxy-user', + }, + }, }; assert.deepEqual(operation.decodeInput(input), input); assert.deepEqual( @@ -1022,6 +1125,28 @@ describe('Runtime Host bootstrap protocol', () => { }, }, ); + assert.deepEqual( + operation.decodeOutput({ + kind: 'proxy_target_mismatch', + expected: input.credential.expectedTarget, + actual: { + protocol: 'https', + host: 'proxy.example', + port: 8443, + username: 'other-user', + }, + }), + { + kind: 'proxy_target_mismatch', + expected: input.credential.expectedTarget, + actual: { + protocol: 'https', + host: 'proxy.example', + port: 8443, + username: 'other-user', + }, + }, + ); assert.throws( () => operation.decodeOutput({ diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index 96eaa316da..42ad1890e7 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -28,7 +28,10 @@ import type { ConnectionCatalogSnapshot, CredentialLocator, } from '@maka/core/runtime-policy'; -import { REQUEST_BODY_OVERLAY_MAX_BYTES } from '@maka/core/runtime-policy'; +import { + connectionCredentialTarget, + REQUEST_BODY_OVERLAY_MAX_BYTES, +} from '@maka/core/runtime-policy'; import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '@maka/runtime/test-only/fake-backend'; import { type MakaToolContext } from '@maka/runtime/tool-runtime'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; @@ -882,6 +885,94 @@ test('returns connection_not_found when deleting a credential after its connecti }); }); +test('two Host clients cannot write an imported credential after retargeting its Connection', async () => { + await withCoordinator(async ({ coordinator, stores }) => { + const created = await stores.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: 'bound-import', + name: 'Bound import', + providerType: 'openai', + enabled: true, + enabledModelIds: [], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) return; + const locator: CredentialLocator = { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }; + const seeded = await stores.credentialVault.set({ + locator, + expected: null, + secret: 'target-secret', + }); + assert.equal(seeded.kind, 'committed'); + if (seeded.kind !== 'committed') return; + const status = seeded.snapshot.entries.find((entry) => entry.locator.scope === 'connection'); + assert.ok(status?.configured); + if (!status?.configured) return; + + // Client A observed this exact target and credential generation. + const expectedConnection = connectionCredentialTarget(connection); + const expectedCredential = { + credentialId: status.credentialId, + revision: status.revision, + }; + + // Client B wins the Host mutation lane and retargets the same entity. + const moved = await coordinator.handlers['connection.catalog.update']( + { + expected: { + connectionId: connection.connectionId, + revision: connection.revision, + }, + changes: { + name: connection.name, + baseUrl: 'https://target-relay.example/v1', + enabled: connection.enabled, + enabledModelIds: connection.enabledModelIds, + relayModelProfiles: null, + }, + }, + context, + ); + assert.equal(moved.ok, true); + if (!moved.ok || moved.result.kind !== 'committed') return; + + const stale = await coordinator.handlers['credential.vault.set']( + { + locator, + expected: expectedCredential, + expectedConnection, + secret: 'source-import-secret', + }, + context, + ); + + assert.deepEqual(stale, { + ok: true, + result: { + kind: 'connection_stale', + expected: { + connectionId: connection.connectionId, + revision: connection.revision, + }, + actual: moved.result.connection, + }, + }); + assert.equal( + (await stores.operations.exportCredentialMaterial(locator))?.secret, + 'target-secret', + ); + }); +}); + test('a fully profiled relay catalog paginates with profiles riding per item', async () => { await withCoordinator(async ({ coordinator, stores }) => { // Every claim in one header table used to be what made a long catalog diff --git a/packages/runtime-host/src/protocol/configuration.ts b/packages/runtime-host/src/protocol/configuration.ts index 1da6c4f375..a23b53583b 100644 --- a/packages/runtime-host/src/protocol/configuration.ts +++ b/packages/runtime-host/src/protocol/configuration.ts @@ -19,7 +19,13 @@ import { decodeCredentialLocator, + decodeConnectionCredentialTarget, + decodeConnectionVersionBasis, + normalizeNetworkProxyCredentialTarget, REQUEST_HEADERS_MAX_BYTES, + type ConnectionCredentialTarget, + type ConnectionVersionBasis, + type NetworkProxyCredentialTarget, type CredentialLocator, } from '@maka/core/runtime-policy'; import { @@ -43,13 +49,19 @@ const ERRORS = [ export interface ConfigurationCredentialExportInput { readonly locator: CredentialLocator; + readonly expectedConnection?: ConnectionCredentialTarget; } export interface ConfigurationCredentialExportResult { readonly credential: { readonly locator: CredentialLocator; readonly secretBase64: string; + readonly proxyTarget?: NetworkProxyCredentialTarget; } | null; + readonly connectionStale?: { + readonly expected: ConnectionVersionBasis; + readonly actual: ConnectionVersionBasis | null; + }; } export const CONFIGURATION_OPERATION_SPECS = { @@ -69,24 +81,62 @@ export const CONFIGURATION_OPERATION_SPECS = { function decodeConfigurationCredentialExportInput( value: unknown, ): ConfigurationCredentialExportInput { - const input = requireExactRecord(value, 'configuration credential export input', ['locator']); - return { locator: decodeLocator(input.locator) }; + const input = requireShapedRecord( + value, + 'configuration credential export input', + ['locator'], + ['expectedConnection'], + ); + const locator = decodeLocator(input.locator); + const expectedConnection = + input.expectedConnection === undefined + ? undefined + : decodeDomainValue(() => decodeConnectionCredentialTarget(input.expectedConnection)); + if (expectedConnection && locator.scope !== 'connection') { + throw invalidProtocolFrame('Only connection credential exports accept a target basis'); + } + return { + locator, + ...(expectedConnection === undefined ? {} : { expectedConnection }), + }; } function decodeConfigurationCredentialExportResult( value: unknown, ): ConfigurationCredentialExportResult { - const result = requireExactRecord(value, 'configuration credential export result', [ - 'credential', - ]); - if (result.credential === null) return { credential: null }; + const result = requireShapedRecord( + value, + 'configuration credential export result', + ['credential'], + ['connectionStale'], + ); + const connectionStale = + result.connectionStale === undefined + ? undefined + : decodeConnectionStale(result.connectionStale); + if (connectionStale && result.credential !== null) { + throw invalidProtocolFrame('A stale connection export must not include credential material'); + } + if (result.credential === null) { + return { + credential: null, + ...(connectionStale === undefined ? {} : { connectionStale }), + }; + } const entry = requireShapedRecord( result.credential, 'exported configuration credential', ['locator', 'secretBase64'], - [], + ['proxyTarget'], ); const locator = decodeLocator(entry.locator); + const proxyTarget = + entry.proxyTarget === undefined + ? undefined + : decodeDomainValue(() => normalizeNetworkProxyCredentialTarget(entry.proxyTarget)); + if (proxyTarget && locator.scope !== 'network_proxy') { + throw invalidProtocolFrame('Only proxy credentials may carry a proxy target'); + } const maxBytes = locator.scope === 'connection' && locator.kind === 'request_headers' ? REQUEST_HEADERS_MAX_BYTES @@ -95,12 +145,39 @@ function decodeConfigurationCredentialExportResult( credential: { locator, secretBase64: decodeCredentialSecretBase64(entry.secretBase64, maxBytes), + ...(proxyTarget === undefined ? {} : { proxyTarget }), }, + ...(connectionStale === undefined ? {} : { connectionStale }), }; requireEncodedByteLimit(decoded, 'configuration credential export result', RESULT_MAX_BYTES); return decoded; } +function decodeConnectionStale(value: unknown): { + expected: ConnectionVersionBasis; + actual: ConnectionVersionBasis | null; +} { + const stale = requireExactRecord(value, 'configuration credential stale connection', [ + 'expected', + 'actual', + ]); + return { + expected: decodeDomainValue(() => decodeConnectionVersionBasis(stale.expected)), + actual: + stale.actual === null + ? null + : decodeDomainValue(() => decodeConnectionVersionBasis(stale.actual)), + }; +} + +function decodeDomainValue(decode: () => T): T { + try { + return decode(); + } catch { + throw invalidProtocolFrame('Invalid configuration credential connection basis'); + } +} + function decodeCredentialSecretBase64(value: unknown, maxBytes: number): string { const encoded = requireUtf8String( value, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index ece5f18a74..8d1fa74e18 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 79 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 80 as const; +// 80: Configuration credential transfer binds proxy destinations and +// Connection credentials to exact Host-owned targets before secret access. // 79: Proxy policy and credentials commit through one recoverable Host command. // Older peers can split the writes and violate the shared credential basis. // 78: OAuth login targets explicit create/existing Connection entities and diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 1717179d02..0afa41b771 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -40,6 +40,7 @@ import { normalizeRemoveCatalogConnectionInput, normalizeOptionalRequestBodyOverlay, normalizeNetworkProxyUpdate, + normalizeNetworkProxyCredentialTarget, normalizeRequestHeaderUpdates, normalizeRuntimePolicyMutation, normalizeSetCredentialInput, @@ -58,6 +59,7 @@ import { type CredentialVersionBasis, type DeleteCredentialInput, type MutateRuntimePolicyInput, + type NetworkProxyCredentialTarget, type RemoveCatalogConnectionInput, type RequestHeaderUpdate, type RevisionConflict, @@ -113,6 +115,11 @@ export type RuntimePolicyNetworkProxyUpdateResult = readonly credentialStatus: CredentialStatus; } | RevisionConflict + | { + readonly kind: 'proxy_target_mismatch'; + readonly expected: NetworkProxyCredentialTarget; + readonly actual: NetworkProxyCredentialTarget; + } | CredentialStale; export type ConnectionCatalogCursor = @@ -218,6 +225,7 @@ export type CredentialVaultQueryResult = export type SetCredentialResult = | CredentialCommitted | { readonly kind: 'connection_not_found' } + | ConnectionStale | CredentialStale; export type DeleteCredentialResult = | CredentialCommitted @@ -464,6 +472,18 @@ function decodeRuntimePolicyNetworkProxyUpdateResult( }; } if (item.kind === 'credential_stale') return credentialStale(item); + if (item.kind === 'proxy_target_mismatch') { + const mismatch = requireExactRecord(item, 'network proxy target mismatch', [ + 'kind', + 'expected', + 'actual', + ]); + return { + kind: 'proxy_target_mismatch', + expected: decodeDomain(() => normalizeNetworkProxyCredentialTarget(mismatch.expected)), + actual: decodeDomain(() => normalizeNetworkProxyCredentialTarget(mismatch.actual)), + }; + } return revisionConflict(item, 'network proxy update result'); } @@ -884,6 +904,7 @@ function decodeSetCredentialResult(value: unknown): SetCredentialResult { requireExactRecord(item, 'credential connection not found result', ['kind']); return { kind: 'connection_not_found' }; } + if (item.kind === 'connection_stale') return connectionStale(item); return credentialStale(item); } diff --git a/packages/runtime-host/src/server/configuration-coordinator.ts b/packages/runtime-host/src/server/configuration-coordinator.ts index 143f0b5a69..0f6bd0963c 100644 --- a/packages/runtime-host/src/server/configuration-coordinator.ts +++ b/packages/runtime-host/src/server/configuration-coordinator.ts @@ -34,11 +34,30 @@ export class HostConfigurationCoordinator { input: ConfigurationCredentialExportInput, ): Promise> { try { - const material = await this.policy.exportCredentialMaterial(input.locator); + const exported = input.expectedConnection + ? await this.policy.exportCredentialMaterial(input.locator, input.expectedConnection) + : { + kind: 'exported' as const, + material: await this.policy.exportCredentialMaterial(input.locator), + }; + if (exported.kind === 'connection_stale') { + return { + ok: true, + result: { + credential: null, + connectionStale: { + expected: exported.expected, + actual: exported.actual, + }, + }, + }; + } + const material = exported.material; const credential = material ? { locator: material.locator, secretBase64: Buffer.from(material.secret, 'utf8').toString('base64'), + ...(material.proxyTarget === undefined ? {} : { proxyTarget: material.proxyTarget }), } : null; return { ok: true, result: { credential } }; diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 17cb49b9f6..077255c700 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -268,7 +268,11 @@ export class HostRuntimePolicyCoordinator { ): Promise> { return this.#storeMutation(async () => { const result = await this.#stores.credentialVault.set(input); - if (result.kind === 'connection_not_found' || result.kind === 'credential_stale') { + if ( + result.kind === 'connection_not_found' || + result.kind === 'connection_stale' || + result.kind === 'credential_stale' + ) { return result; } const status = result.snapshot.entries.find((entry) => @@ -286,6 +290,9 @@ export class HostRuntimePolicyCoordinator { ): Promise> { return this.#storeMutation(async () => { const result = await this.#stores.credentialVault.delete(input); + if (result.kind === 'connection_stale') { + throw invariantFailure('Credential deletion returned an impossible connection conflict'); + } if (result.kind === 'connection_not_found' || result.kind === 'credential_stale') { return result; } diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index bae051f4b8..58606d02ca 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -2105,6 +2105,87 @@ describe('runtime policy stores', () => { }); }); + test('a bound connection credential write rejects revision, provider, and endpoint drift', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('bound-import', 'openai', 'Bound import'), + ); + const locator = connectionCredential(connection, 'api_key'); + const seeded = await stores.credentialVault.set({ + locator, + expected: null, + secret: 'existing-target-secret', + }); + assert.equal(seeded.kind, 'committed'); + if (seeded.kind !== 'committed') return; + const status = await getCredentialStatus(stores.credentialVault, locator); + const sourceTarget = { + ...connectionBasis(connection), + slug: connection.slug, + providerType: connection.providerType, + effectiveBaseUrl: new URL(PROVIDER_DEFAULTS.openai.baseUrl).toString(), + }; + + const moved = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: 'https://target-relay.example/v1', + enabled: connection.enabled, + enabledModelIds: connection.enabledModelIds, + }, + }); + assert.equal(moved.kind, 'committed'); + if (moved.kind !== 'committed') return; + const current = moved.snapshot.connections.find( + (item) => item.connectionId === connection.connectionId, + ); + assert.ok(current); + if (!current) return; + + const staleRevision = await stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + expectedConnection: sourceTarget, + secret: 'must-not-cross-targets', + } as never); + assert.deepEqual(staleRevision, { + kind: 'connection_stale', + expected: connectionBasis(connection), + actual: connectionBasis(current), + }); + assert.deepEqual(await stores.operations.exportCredentialMaterial(locator, sourceTarget), { + kind: 'connection_stale', + expected: connectionBasis(connection), + actual: connectionBasis(current), + }); + + for (const expectedConnection of [ + { ...sourceTarget, ...connectionBasis(current) }, + { ...sourceTarget, ...connectionBasis(current), providerType: 'deepseek' }, + ]) { + const mismatch = await stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + expectedConnection, + secret: 'must-not-cross-targets', + } as never); + assert.deepEqual(mismatch, { + kind: 'connection_stale', + expected: connectionBasis(current), + actual: connectionBasis(current), + }); + } + + assert.equal( + (await stores.operations.exportCredentialMaterial(locator))?.secret, + 'existing-target-secret', + ); + }); + }); + test('reports unknown outcome when credential persistence fails after clearing verified state', { skip: process.platform === 'win32' @@ -3089,6 +3170,80 @@ describe('runtime policy stores', () => { }); }); + test('a bound proxy credential import cannot replace the secret after the proxy target changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const source = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'proxy-a.example', + port: 8080, + authEnabled: true, + username: 'source-user', + }, + credential: { kind: 'replace', secret: 'existing-secret' }, + }); + assert.equal(source.kind, 'committed'); + if (source.kind !== 'committed' || !source.credentialStatus.configured) return; + + const retargeted = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: source.snapshot.revision, + expectedCredential: credentialBasis(source.credentialStatus), + networkProxy: { + ...source.snapshot.policy.networkProxy, + host: 'proxy-b.example', + username: 'target-user', + }, + credential: { kind: 'keep' }, + }); + assert.equal(retargeted.kind, 'committed'); + if (retargeted.kind !== 'committed') return; + + const outcome = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: retargeted.snapshot.revision, + expectedCredential: credentialBasis(source.credentialStatus), + networkProxy: retargeted.snapshot.policy.networkProxy, + credential: { + kind: 'replace', + secret: 'source-import-secret', + expectedTarget: { + protocol: 'http', + host: 'proxy-a.example', + port: 8080, + username: 'source-user', + }, + }, + } as never); + + assert.deepEqual(outcome, { + kind: 'proxy_target_mismatch', + expected: { + protocol: 'http', + host: 'proxy-a.example', + port: 8080, + username: 'source-user', + }, + actual: { + protocol: 'http', + host: 'proxy-b.example', + port: 8080, + username: 'target-user', + }, + }); + const exported = await stores.operations.exportCredentialMaterial(proxyCredential()); + assert.equal(exported?.secret, 'existing-secret'); + assert.deepEqual(exported?.proxyTarget, { + protocol: 'http', + host: 'proxy-b.example', + port: 8080, + username: 'target-user', + }); + }); + }); + test('proxy replacement failure before vault publication leaves both stores unchanged', { skip: process.platform === 'win32' diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index d5630065a1..5d124e8d83 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -20,6 +20,7 @@ import type { ConnectionCatalogMutationResult, ConnectionCatalogSnapshot, + ConnectionCredentialTarget, CreateCatalogConnectionInput, CredentialLocator, CredentialMutationResult, @@ -238,7 +239,15 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic }, operations: { updateNetworkProxy: (input) => coordinator.updateNetworkProxy(input), - exportCredentialMaterial: (locator) => coordinator.exportCredentialMaterial(locator), + exportCredentialMaterial: (( + locator: CredentialLocator, + expectedConnection?: ConnectionCredentialTarget, + ) => + expectedConnection + ? coordinator.exportCredentialMaterial(locator, expectedConnection) + : coordinator.exportCredentialMaterial( + locator, + )) as OperationCoordinator['exportCredentialMaterial'], getConnectionRequestHeaders: (connectionId) => coordinator.getConnectionRequestHeaders(connectionId), replaceConnectionRequestHeaders: (connectionId, updates) => diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index bc3ab5830f..94b39c5e7e 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -22,6 +22,8 @@ import { isDeepStrictEqual } from 'node:util'; import { CONNECTION_CATALOG_MAX_CONNECTIONS, decodeConnectionModelId, + connectionCredentialTarget, + decodeConnectionCredentialTarget, decodeConnectionSlug, decodeProviderType, decodeRuntimePolicyEntityId, @@ -37,8 +39,10 @@ import { normalizeCredentialSecret, normalizeCatalogConnectionBaseUrl, normalizeNetworkProxyUpdate, + networkProxyCredentialTarget, type ConnectionCatalogEntry, type ConnectionCatalogSnapshot, + type ConnectionCredentialTarget, type ConnectionVersionBasis, type ConnectionModelDiscoveryResult, type ConnectionTestSummary, @@ -103,6 +107,7 @@ import { connectionRequestHeadersLocator, type CredentialStatusQueryResult, type BeginConnectionTestResult, + type BoundCredentialMaterialExportResult, type BeginModelFetchResult, type BeginInteractiveOAuthLoginResult, type CompareAndSetOAuthCredentialInput, @@ -347,6 +352,21 @@ export class RuntimePolicyCoordinator { }); if (preparedPolicy.kind !== 'ready') return preparedPolicy; + if ( + input.credential.kind === 'replace' && + input.credential.expectedTarget && + !isDeepStrictEqual( + networkProxyCredentialTarget(policy.policy.networkProxy), + input.credential.expectedTarget, + ) + ) { + return deepFreeze({ + kind: 'proxy_target_mismatch' as const, + expected: input.credential.expectedTarget, + actual: networkProxyCredentialTarget(policy.policy.networkProxy), + }); + } + const vault = await this.vault.read(root); const existing = findCredential(vault, networkProxyCredentialLocator()); if (!matchesCredentialExpectation(existing, input.expectedCredential)) { @@ -462,6 +482,19 @@ export class RuntimePolicyCoordinator { if (!connection) { return deepFreeze({ kind: 'connection_not_found' as const }); } + if ( + input.expectedConnection && + !isDeepStrictEqual(connectionCredentialTarget(connection), input.expectedConnection) + ) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected: { + connectionId: input.expectedConnection.connectionId, + revision: input.expectedConnection.revision, + }, + actual: connectionBasis(connection), + }); + } assertConnectionIsWritable(connection); const required = connectionCredentialLocator( connection.connectionId, @@ -821,15 +854,63 @@ export class RuntimePolicyCoordinator { exportCredentialMaterial( rawLocator: CredentialLocator, - ): Promise { + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection: ConnectionCredentialTarget, + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection?: ConnectionCredentialTarget, + ): Promise { return this.inLane(async (root) => { const locator = decodeCredentialInput(() => decodeCredentialLocator(rawLocator)); + const expectedConnection = rawExpectedConnection + ? decodeConnectionInput(() => decodeConnectionCredentialTarget(rawExpectedConnection)) + : undefined; + if (expectedConnection && locator.scope !== 'connection') { + throw codecError( + 'invalid_credential_input', + 'Only connection credentials accept a connection target basis', + ); + } if (locator.scope === 'connection') { const catalog = await this.catalog.read(root); - if (!this.validateConnectionCredentialLocator(catalog, locator)) return null; + const connection = findConnection(catalog, locator); + if ( + expectedConnection && + (!connection || + !isDeepStrictEqual(connectionCredentialTarget(connection), expectedConnection)) + ) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected: { + connectionId: expectedConnection.connectionId, + revision: expectedConnection.revision, + }, + actual: connection ? connectionBasis(connection) : null, + }); + } + if (!this.validateConnectionCredentialLocator(catalog, locator)) { + return expectedConnection + ? deepFreeze({ kind: 'exported' as const, material: null }) + : null; + } } const credential = findCredential(await this.vault.read(root), locator); - return credential ? credentialMaterial(credential) : null; + const material = credential + ? { + ...credentialMaterial(credential), + ...(locator.scope === 'network_proxy' + ? { + proxyTarget: networkProxyCredentialTarget( + (await this.policy.read(root)).policy.networkProxy, + ), + } + : {}), + } + : null; + return expectedConnection ? deepFreeze({ kind: 'exported' as const, material }) : material; }); } diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 1580703aa8..d14502bd83 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -20,6 +20,8 @@ import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot, + ConnectionCredentialTarget, + ConnectionVersionBasis, ConnectionModelDiscoveryResult, ConnectionOnboardingTarget, ConnectionTestSummary, @@ -29,6 +31,7 @@ import type { CredentialStatus, CredentialVersionBasis, RuntimePolicy, + NetworkProxyCredentialTarget, UpdateNetworkProxyInput, UpdateNetworkProxyResult, RequestHeaderUpdate, @@ -48,8 +51,20 @@ export type UnavailableProviderActionAvailability = Exclude< export interface RuntimePolicyCredentialMaterial extends CredentialVersionBasis { readonly secret: string; + readonly proxyTarget?: NetworkProxyCredentialTarget; } +export type BoundCredentialMaterialExportResult = + | { + readonly kind: 'exported'; + readonly material: RuntimePolicyCredentialMaterial | null; + } + | { + readonly kind: 'connection_stale'; + readonly expected: ConnectionVersionBasis; + readonly actual: ConnectionVersionBasis | null; + }; + export interface RuntimePolicyOperationSecretMaterial { readonly connection?: RuntimePolicyCredentialMaterial; readonly requestHeaders?: RuntimePolicyCredentialMaterial; @@ -362,6 +377,10 @@ export interface RuntimePolicyOperationCoordinator { exportCredentialMaterial( locator: CredentialLocator, ): Promise; + exportCredentialMaterial( + locator: CredentialLocator, + expectedConnection: ConnectionCredentialTarget, + ): Promise; getConnectionRequestHeaders(connectionId: string): Promise; replaceConnectionRequestHeaders( connectionId: string, From 463eb56df597e7d1751039268f94577d8878d2ca Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:01:38 +0800 Subject: [PATCH 18/20] fix(config): reject unbound credential imports Generated-by: Codex --- .../__tests__/config-transfer-service.test.ts | 24 ++++++++++++++ .../runtime-host-config-ipc-main.test.ts | 32 ++++++++++++++++--- .../src/main/config-transfer-service.ts | 7 ++-- .../src/main/runtime-host-config-ipc-main.ts | 8 +++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index 4810f5fc87..2d15fb0eab 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -241,6 +241,30 @@ describe('config-transfer-service', () => { assert.deepEqual(result.credentials, { applied: 1, skipped: 0 }); }); + it('skips a credentials-only entry without a source connection binding', async () => { + const { deps, setCreds } = makeDeps(); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['credentials'] as const, + data: { + credentials: [ + { + slug: 'deepseek-main', + kind: 'api_key', + value: 'sk-unbound-source', + }, + ], + }, + }; + + const result = await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(setCreds, []); + assert.deepEqual(result.credentials, { applied: 0, skipped: 1 }); + }); + it('skips a credentials-only entry when the target slug belongs to another provider', async () => { const { deps, setCreds } = makeDeps(); const bundle = { 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 1eb066807b..8cc82e4608 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 @@ -416,14 +416,28 @@ test('Runtime Host config export writes an empty v1 proxy password when none is }); test('Runtime Host config import adapts v1 proxy passwords only with credential consent', () => { - const replace = adaptRuntimeHostConfigImport( - importBundle(['settings', 'credentials'], 'complete-secret'), - ); + const replaceBundle = importBundle(['settings', 'credentials'], 'complete-secret'); + (replaceBundle.data.settings as Record).network.proxy.credentialTarget = { + protocol: 'https', + host: 'Source.Proxy.Example', + port: 8443, + username: 'source-user', + }; + const replace = adaptRuntimeHostConfigImport(replaceBundle); assert.deepEqual( (replace.data.settings as Record).network.proxy, { host: '10.0.0.2', - credential: { kind: 'replace', secret: 'complete-secret' }, + credential: { + kind: 'replace', + secret: 'complete-secret', + expectedTarget: { + protocol: 'https', + host: 'source.proxy.example', + port: 8443, + username: 'source-user', + }, + }, }, ); @@ -452,6 +466,16 @@ test('Runtime Host config import adapts v1 proxy passwords only with credential ); }); +test('Runtime Host config import rejects an unbound legacy proxy password', () => { + assert.throws( + () => + adaptRuntimeHostConfigImport( + importBundle(['settings', 'credentials'], 'legacy-secret'), + ), + /proxy.*target.*binding/i, + ); +}); + test('Runtime Host config import rejects a non-string v1 password during preflight', () => { assert.throws( () => adaptRuntimeHostConfigImport(importBundle(['settings', 'credentials'], 42)), diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index fe632cafbb..13c6972208 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -83,8 +83,9 @@ export async function applyConfigImport( // overwritten by this import. Credentials-only bundles instead require an // existing slug whose provider and effective endpoint match the export. const credentialTargets = new Map(); + const hasConnectionSnapshot = Array.isArray(bundle.data.connections); - if (Array.isArray(bundle.data.connections)) { + if (hasConnectionSnapshot) { const incoming = bundle.data.connections as LlmConnection[]; const existing = await deps.connectionStore.list(); const plan = planConnectionMerge(existing, incoming, strategy); @@ -147,7 +148,9 @@ export async function applyConfigImport( skipped += 1; continue; } - const binding = entry.connection ?? credentialConnectionBinding(target); + const binding = + entry.connection ?? + (hasConnectionSnapshot ? credentialConnectionBinding(target) : undefined); if (!matchesCredentialConnection(binding, target)) { skipped += 1; continue; 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 7560050c65..69d3d8148d 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -575,6 +575,14 @@ export function adaptRuntimeHostConfigImport(bundle: ConfigBundle): ConfigBundle 'Cannot import a proxy password while proxy authentication is disabled.', ); } + if ( + includesCredentials && + typeof password === 'string' && + password.length > 0 && + credentialTarget === undefined + ) { + throw new Error('Proxy password import requires a target binding.'); + } const { password: _password, From a090b83844d5c40947d6a670376d743db4ddad6b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:30:07 +0800 Subject: [PATCH 19/20] test(release): make state root qualification portable --- scripts/qualify-released-cli-state-root.test.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/qualify-released-cli-state-root.test.mjs b/scripts/qualify-released-cli-state-root.test.mjs index 9a02a466ba..8f99dc5e3c 100644 --- a/scripts/qualify-released-cli-state-root.test.mjs +++ b/scripts/qualify-released-cli-state-root.test.mjs @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, join, resolve } from 'node:path'; +import { isAbsolute, join, resolve, sep } from 'node:path'; import test from 'node:test'; import { assertExpectedEpochRelation, @@ -197,16 +197,20 @@ test('durable state covers the control namespace, not only the State Root', () = // State Root, not inside it. A golden copy scoped to the State Root alone // restored a workspace whose control records had already moved on, so the // transition it proved was never the one a user performs. - const locations = durableStateLocations('/qualification-scope'); + const scope = resolve(tmpdir(), 'qualification-scope'); + const locations = durableStateLocations(scope); assert.ok(locations.length >= 2); - assert.ok(locations.some(({ live }) => live === '/qualification-scope/state-root')); + assert.ok(locations.some(({ live }) => live === join(scope, 'state-root'))); assert.ok( locations.some(({ live }) => live.endsWith(join('.cache', 'maka', 'runtime-hosts'))), 'the account-local control namespace must be captured and restored', ); for (const { live, golden } of locations) { assert.ok(isAbsolute(live) && isAbsolute(golden)); - assert.ok(!golden.startsWith(`${live}/`), 'a golden copy must not nest inside its live path'); + assert.ok( + !golden.startsWith(`${live}${sep}`), + 'a golden copy must not nest inside its live path', + ); } }); From 9576eee5279d1fd5392fc7cc1ee530881d8ce2b0 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:09:37 +0800 Subject: [PATCH 20/20] fix(runtime-host): advance proxy protocol compatibility epoch --- packages/runtime-host/src/__tests__/protocol.test.ts | 7 +++++++ packages/runtime-host/src/protocol/index.ts | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 0dc302e1da..c5d2461e90 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -259,6 +259,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 84); }); + test('publishes a new compatibility epoch for explicit proxy credential updates', () => { + // Epoch 87 predates the Host-owned proxy credential mutation and its + // target-bound transfer result. Older peers cannot safely exchange these + // shapes, so the merged PR must advance the handshake boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 87); + }); + test('publishes a new compatibility epoch for the removed execution.inspect.resolve operation', () => { // Epoch 63 peers still know execution.inspect.resolve and would send it // only to fail mid-connection now that it is gone, so its removal must diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 856b3cef71..ad645933f2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 87 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 88 as const; +// 88: Configuration credential transfer binds proxy destinations and +// Connection credentials to exact Host-owned targets before secret access. +// Proxy policy and credentials commit through one recoverable Host command; +// older peers can split the writes and violate the shared credential basis. // 87: The connection catalog projects each model as the Host resolved it — // a `catalog_entry` item per model, counted by the connection header. Clients // render those entries instead of merging the stored row against their own @@ -109,10 +113,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 87 as const; // surfaces. Older peers cannot safely exchange these strict operation shapes. // 84: Message content carries Host-bound directory references. Older peers // reject this field and cannot preserve its identity through admission/replay. -// Configuration credential transfer also binds proxy destinations and -// Connection credentials to exact Host-owned targets before secret access. -// Proxy policy and credentials commit through one recoverable Host command; -// older peers can split the writes and violate the shared credential basis. // 83: WorkHub Coordination actions add linked replacement proposals, // destructive user confirmation, and replacement results. Older peers reject // these closed action and result shapes.