You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/02 04:01:39 UTC
(maka) branch main updated: fix(desktop): make proxy credential updates explicit and Host-owned (#3704)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 6c632b1339 fix(desktop): make proxy credential updates explicit and Host-owned (#3704)
6c632b1339 is described below
commit 6c632b1339053903b75ea1eada9eddc4ac08a9d0
Author: ooopSok <81...@users.noreply.github.com>
AuthorDate: Wed Sep 2 12:01:34 2026 +0800
fix(desktop): make proxy credential updates explicit and Host-owned (#3704)
* fix(desktop): prevent proxy password mask corruption
Generated-by: OpenAI Codex
* fix(runtime-host): make proxy updates recoverable
Generated-by: OpenAI Codex
* chore(test): update Windows skip inventory
Generated-by: OpenAI Codex
* fix(desktop): close proxy password review gaps
Generated-by: OpenAI Codex
* ci: retrigger checks
Generated-by: OpenAI Codex
* fix(storage): keep proxy secrets inside credential vault
Generated-by: OpenAI Codex
* ci: retrigger checks
Generated-by: OpenAI Codex
* chore(test): refresh Windows skip inventory
Generated-by: OpenAI Codex
* fix(desktop): address proxy password review findings
* chore(test): refresh Windows skip inventory
Generated-by: OpenAI Codex
* fix(desktop): preserve credentials-only restores
* fix(desktop): isolate proxy password draft feature
* fix(desktop): avoid untracked proxy surface
* fix(desktop): keep proxy test entry minimal
* fix(desktop): expand sidebar in proxy e2e
* fix(desktop): bind restored credentials to targets
* fix(config): bind credential transfers to host targets
* fix(config): reject unbound credential imports
Generated-by: Codex
* test(release): make state root qualification portable
* fix(runtime-host): advance proxy protocol compatibility epoch
---
apps/desktop/e2e/proxy-password-editing.spec.ts | 152 +++++++
apps/desktop/renderer-architecture.json | 2 +-
.../main/__tests__/config-transfer-service.test.ts | 177 +++++++-
.../src/main/__tests__/password-input.test.ts | 268 ++++++++++++
.../main/__tests__/proxy-password-draft.test.ts | 166 ++++++++
.../__tests__/runtime-host-config-ipc-main.test.ts | 470 ++++++++++++++++++++-
.../runtime-host-settings-ipc-main.test.ts | 412 +++++++++++++++++-
.../main/__tests__/settings-ipc-helpers.test.ts | 42 +-
.../main/__tests__/settings-resource-state.test.ts | 26 +-
.../__tests__/settings-test-result-copy.test.ts | 39 ++
apps/desktop/src/main/config-transfer-service.ts | 99 ++++-
apps/desktop/src/main/runtime-host-boot.ts | 21 +-
apps/desktop/src/main/runtime-host-client.ts | 6 +
.../src/main/runtime-host-config-ipc-main.ts | 370 +++++++++++++---
.../src/main/runtime-host-settings-ipc-main.ts | 316 ++++++++++----
apps/desktop/src/main/settings-ipc-helpers.ts | 83 +---
apps/desktop/src/preload/bridge-contract.d.ts | 5 +-
apps/desktop/src/preload/preload.ts | 5 +-
.../controller/use-proxy-password-draft.ts | 39 ++
.../src/renderer/features/network-proxy/index.ts | 35 ++
.../network-proxy/model/proxy-password-draft.ts | 105 +++++
.../src/renderer/features/network-proxy/testing.ts | 23 +
.../renderer/locales/settings-preferences-copy.ts | 3 +
.../renderer/locales/settings-test-result-copy.ts | 6 +
.../renderer/settings/general-settings-page.tsx | 94 ++++-
.../src/renderer/settings/password-input.tsx | 34 +-
.../renderer/settings/settings-snapshot-cache.ts | 9 +-
.../src/renderer/settings/settings-surface.tsx | 8 +-
.../stories/settings/settings-pages.stories.tsx | 34 +-
docs/windows-test-inventory.md | 8 +-
packages/core/src/__tests__/settings.test.ts | 35 ++
packages/core/src/runtime-policy.ts | 65 ++-
.../src/runtime-policy/connection-catalog-codec.ts | 63 +++
.../src/runtime-policy/credential-vault-codec.ts | 16 +-
packages/core/src/runtime-policy/policy-codec.ts | 100 +++++
packages/core/src/settings.ts | 62 ++-
packages/core/src/settings/network-settings.ts | 2 +-
.../runtime-host/src/__tests__/protocol.test.ts | 206 +++++++++
.../__tests__/runtime-policy-coordinator.test.ts | 166 +++++++-
.../runtime-host/src/protocol/configuration.ts | 91 +++-
packages/runtime-host/src/protocol/index.ts | 6 +-
.../runtime-host/src/protocol/network-proxy.ts | 9 +-
packages/runtime-host/src/protocol/operations.ts | 1 +
.../runtime-host/src/protocol/runtime-policy.ts | 76 ++++
.../src/server/configuration-coordinator.ts | 21 +-
.../src/server/network-proxy-coordinator.ts | 1 -
.../src/server/runtime-policy-coordinator.ts | 37 +-
.../src/__tests__/runtime-policy-stores.test.ts | 458 ++++++++++++++++++++
.../__tests__/settings-store-onboarding.test.ts | 39 ++
packages/storage/src/runtime-policy-stores.ts | 12 +-
packages/storage/src/runtime-policy/coordinator.ts | 234 +++++++++-
packages/storage/src/runtime-policy/operations.ts | 22 +
packages/storage/src/settings-store.ts | 48 +--
scripts/qualify-released-cli-state-root.test.mjs | 5 +-
54 files changed, 4431 insertions(+), 401 deletions(-)
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..879d1045f9
--- /dev/null
+++ b/apps/desktop/e2e/proxy-password-editing.spec.ts
@@ -0,0 +1,152 @@
+/*
+ * 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,
+ ensureSidebarExpanded,
+} from "./fixtures";
+
+test("proxy password drafts save once, reload safely, and authenticate offline", async ({
+ window: page,
+}) => {
+ const username = "proxy-user";
+ const password = "complete-secret";
+ const replacementPassword = "replacement-secret";
+ let acceptAuthorization!: (value: string | undefined) => void;
+ const authorization = new Promise<string | undefined>((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<void>((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 ensureSidebarExpanded(page);
+ 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 ensureSidebarExpanded(page);
+ 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);
+
+ 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 ensureSidebarExpanded(page);
+ 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}:${replacementPassword}`).toString("base64")}`,
+ );
+ } finally {
+ await new Promise<void>((resolve) => proxy.close(() => resolve()));
+ }
+});
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 02c3e00503..6d7402ca9d 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -3032,6 +3032,7 @@
"actionFactories": [],
"dependencyPaths": {
"../features/connection-settings": 1,
+ "../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,
@@ -3050,7 +3051,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__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts
index a904296104..2d15fb0eab 100644
--- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts
+++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts
@@ -19,12 +19,14 @@
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';
-function conn(slug: string): LlmConnection {
+function conn(
+ slug: string,
+ overrides: Partial<LlmConnection> = {},
+): LlmConnection {
return {
slug,
name: slug,
@@ -33,6 +35,7 @@ function conn(slug: string): LlmConnection {
enabled: true,
createdAt: 1,
updatedAt: 1,
+ ...overrides,
};
}
@@ -58,12 +61,13 @@ function makeDeps(overrides: Partial<ConfigTransferDeps> = {}): {
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) => {
@@ -100,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
@@ -160,6 +186,149 @@ 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('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 = {
+ schemaVersion: 1,
+ exportedAt: '',
+ appVersion: '0.1.0',
+ includedData: ['credentials'] as const,
+ data: {
+ credentials: [
+ {
+ slug: 'deepseek-main',
+ kind: 'api_key',
+ value: 'sk-restored',
+ connection: {
+ providerType: 'deepseek',
+ effectiveBaseUrl: 'https://api.deepseek.com',
+ },
+ },
+ ],
+ },
+ };
+
+ 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('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 = {
+ 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__/password-input.test.ts b/apps/desktop/src/main/__tests__/password-input.test.ts
new file mode 100644
index 0000000000..56d88a688e
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/password-input.test.ts
@@ -0,0 +1,268 @@
+/*
+ * 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 { 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 type * as PasswordInputModule from "../../renderer/settings/password-input.js";
+
+const REPO_ROOT = resolve(import.meta.dirname, "../../../../..");
+
+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("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(
+ 'button[aria-label="Copy"]',
+ );
+
+ 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 | null): void;
+ setDocumentFocused(focused: boolean): void;
+}> {
+ const { PasswordInput } = await importPasswordInput();
+ const { document, window } = parseHTML(
+ '<div id="root"></div><button id="outside">outside</button>',
+ );
+ 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;
+ let enters = 0;
+ let documentFocused = true;
+ const keys: string[] = [];
+ Object.defineProperty(document, "hasFocus", {
+ configurable: true,
+ value: () => documentFocused,
+ });
+ 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;
+ },
+ onEnter: () => {
+ enters += 1;
+ },
+ onKeyDown: (event) => {
+ keys.push(event.key);
+ },
+ 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;
+ },
+ get keyEvents() {
+ return { enters, keys: [...keys] };
+ },
+ focusExit(_from, to) {
+ const handler = reactProps(group).onBlurCapture as (event: {
+ currentTarget: Element;
+ relatedTarget: Element | null;
+ }) => void;
+ handler({ currentTarget: group, relatedTarget: to });
+ },
+ setDocumentFocused(focused) {
+ documentFocused = focused;
+ },
+ };
+}
+
+async function importPasswordInput(): Promise<typeof PasswordInputModule> {
+ 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<string, unknown> {
+ const key = Object.keys(element).find((candidate) =>
+ candidate.startsWith("__reactProps$"),
+ );
+ return key
+ ? ((element as unknown as Record<string, unknown>)[key] as Record<string, unknown>)
+ : {};
+}
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..be3eb22fc7
--- /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/features/network-proxy/testing.js";
+
+function deferred<T = void>() {
+ let resolve!: (value: T | PromiseLike<T>) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((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 d3095f08c6..a3b808d711 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,14 +19,21 @@
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 {
CredentialLocator,
} from '@maka/core/runtime-policy';
+import type { ConfigBundle } from '@maka/storage/config-transfer';
+import {
+ adaptRuntimeHostConfigImport,
+ gatherRuntimeHostConfig,
+} from '../runtime-host-config-ipc-main.js';
import type {
- RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry,
RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot,
-} from '@maka/runtime-host/client';import { gatherRuntimeHostConfig } from '../runtime-host-config-ipc-main.js';
+} from '@maka/runtime-host/client';
const CATALOG: ConnectionCatalogSnapshot = {
revision: 1,
@@ -69,6 +76,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);
@@ -87,10 +95,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),
};
},
},
@@ -101,30 +106,436 @@ test('Runtime Host config export reads selected credentials from Host authority'
const settings = bundle.data.settings as Record<string, any>;
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');
assert.equal(settings.botChat.channels.telegram.token, 'bot-secret');
});
-function settingsWithSecrets(): AppSettings {
- 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',
+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',
+ },
},
- webSearch: {
- providers: { tavily: { apiKey: 'local-tavily-secret' } },
+ ]);
+});
+
+test('Runtime Host credentials-only export includes only schema-v1 credential fields', async () => {
+ const bundle = await gatherRuntimeHostConfig(
+ ['credentials'],
+ {
+ client: {
+ loadConnectionCatalog: async () => CATALOG,
+ exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => {
+ const secret = secretFor(locator);
+ return {
+ credential:
+ secret === null
+ ? null
+ : exportedCredential(locator, secret),
+ };
+ },
+ },
+ appVersion: '0.1.0',
+ getSettings: async () => settingsWithSecrets(),
+ } as never,
+ );
+
+ assert.deepEqual(bundle.includedData, ['settings', 'credentials']);
+ assert.deepEqual(bundle.data.settings, {
+ 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, [
+ {
+ slug: 'deepseek-main',
+ kind: 'api_key',
+ value: 'sk-host',
+ connection: {
+ providerType: 'deepseek',
+ effectiveBaseUrl: 'https://api.deepseek.com/',
+ },
+ },
+ ]);
+});
+
+test('Runtime Host credentials-only proxy export carries a target binding without patching policy', async () => {
+ const exported = await gatherRuntimeHostConfig(
+ ['credentials'],
+ {
+ client: {
+ loadConnectionCatalog: async () => ({ ...CATALOG, connections: [] }),
+ exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => ({
+ credential:
+ locator.scope === 'network_proxy'
+ ? {
+ ...exportedCredential(locator, 'proxy-host'),
+ }
+ : null,
+ }),
+ },
+ appVersion: '0.1.0',
+ getSettings: async () => createDefaultSettings(),
+ } as never,
+ );
+
+ const adapted = adaptRuntimeHostConfigImport(exported);
+ const importedProxy = (adapted.data.settings as Record<string, any>).network.proxy;
+
+ assert.deepEqual(importedProxy, {
+ credential: {
+ kind: 'replace',
+ secret: 'proxy-host',
+ expectedTarget: {
+ protocol: 'http',
+ host: '127.0.0.1',
+ port: 7890,
+ username: '',
+ },
},
- } as unknown as AppSettings;
+ });
+});
+
+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<string, any>;
+ 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 = [
+ {
+ presentScope: 'web_search',
+ expected: {
+ webSearch: { providers: { tavily: { apiKey: 'tavily-host' } } },
+ },
+ },
+ {
+ presentScope: 'network_proxy',
+ expected: {
+ network: {
+ proxy: {
+ password: 'proxy-host',
+ credentialTarget: {
+ protocol: 'http',
+ host: '127.0.0.1',
+ port: 7890,
+ username: '',
+ },
+ },
+ },
+ },
+ },
+ {
+ 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
+ : exportedCredential(locator, secret),
+ };
+ },
+ },
+ 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 () => {
+ 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<string, any>;
+ 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 replaceBundle = importBundle(['settings', 'credentials'], 'complete-secret');
+ (replaceBundle.data.settings as Record<string, any>).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<string, any>).network.proxy,
+ {
+ host: '10.0.0.2',
+ credential: {
+ kind: 'replace',
+ secret: 'complete-secret',
+ expectedTarget: {
+ protocol: 'https',
+ host: 'source.proxy.example',
+ port: 8443,
+ username: 'source-user',
+ },
+ },
+ },
+ );
+
+ const remove = adaptRuntimeHostConfigImport(
+ importBundle(['settings', 'credentials'], ''),
+ );
+ assert.deepEqual(
+ (remove.data.settings as Record<string, any>).network.proxy.credential,
+ { kind: 'delete' },
+ );
+
+ const keep = adaptRuntimeHostConfigImport(
+ importBundle(['settings', 'credentials'], undefined),
+ );
+ assert.equal(
+ 'credential' in (keep.data.settings as Record<string, any>).network.proxy,
+ false,
+ );
+
+ const ignored = adaptRuntimeHostConfigImport(
+ importBundle(['settings'], 'handcrafted-secret'),
+ );
+ assert.deepEqual(
+ (ignored.data.settings as Record<string, any>).network.proxy,
+ { host: '10.0.0.2' },
+ );
+});
+
+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)),
+ /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<string, any>).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 {
+ ...settings,
+ network: {
+ proxy: {
+ ...settings.network.proxy,
+ passwordConfigured: true,
+ },
+ },
+ };
+}
+
+function importBundle(
+ includedData: ConfigBundle['includedData'],
+ password: unknown,
+): ConfigBundle {
+ const proxy: Record<string, unknown> = {
+ 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: [] } : {}),
+ },
+ };
}
function secretFor(locator: CredentialLocator): string | null {
@@ -135,3 +546,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 b11c39fc5b..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
@@ -19,11 +19,20 @@
import assert from "node:assert/strict";
import test from "node:test";
+import {
+ createDefaultSettings,
+ type UpdateAppSettingsInput,
+} from "@maka/core/settings";
import {
createDefaultRuntimePolicy,
type RuntimePolicy,
+ type UpdateNetworkProxyInput,
} 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"];
@@ -35,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 };
@@ -59,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);
@@ -85,7 +97,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 () => {
@@ -95,3 +107,387 @@ 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<void>;
+ failFirstSet?: boolean;
+ proxyTargetMismatch?: 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;
+ const events: string[] = [];
+ const local = createDefaultSettings();
+
+ const client = {
+ async queryRuntimePolicy() {
+ return { revision: policyRevision, 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 };
+ }
+ 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 (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?.();
+ 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;
+ 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<NonNullable<UpdateAppSettingsInput["network"]>["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("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<void>((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<void>((resolve) => {
+ release = resolve;
+ });
+ const fixture = createModuleFixture({
+ beforeSetCredential: () => blocked,
+ failFirstSet: true,
+ });
+ const replace = fixture.module.update({
+ network: {
+ proxy: {
+ authEnabled: true,
+ 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("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<void>((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 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..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,26 +24,22 @@ import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings";
import {
buildSettingsUpdateResult,
maskAppSettings,
- preserveSensitivePlaceholders,
toSettingsTestResult,
} 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 +47,6 @@ describe("settings IPC helpers", () => {
const masked = maskAppSettings(settings);
- assert.equal(masked.network.proxy.password, "");
assert.equal(masked.botChat.channels.telegram.token, "");
});
@@ -72,16 +67,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,
@@ -105,38 +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.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 },
- feishu: { appSecret: SENSITIVE_PLACEHOLDER, appId: "cli_123" },
- },
- },
- },
- 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(
- 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/__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/__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/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts
index a66cc53701..13c6972208 100644
--- a/apps/desktop/src/main/config-transfer-service.ts
+++ b/apps/desktop/src/main/config-transfer-service.ts
@@ -17,11 +17,12 @@
* under the License.
*/
-import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings';
+import type { UpdateAppSettingsInput } from '@maka/core/settings';
import {
reconcileConnectionAfterEnabledModelsChange,
type LlmConnection,
} from '@maka/core/llm-connections';
+import { canonicalConnectionEffectiveBaseUrl } from '@maka/core/runtime-policy';
import {
type ConfigBundle,
type ConnectionConflictStrategy,
@@ -38,6 +39,10 @@ export interface ExportedCredential {
slug: string;
kind: CredentialKind;
value: string;
+ connection?: {
+ providerType: LlmConnection['providerType'];
+ effectiveBaseUrl: string;
+ };
}
const VALID_CREDENTIAL_KINDS: ReadonlySet<string> = new Set<CredentialKind>([
@@ -52,9 +57,11 @@ const VALID_CREDENTIAL_KINDS: ReadonlySet<string> = new Set<CredentialKind>([
export interface ConfigTransferDeps {
connectionStore: { list(): Promise<LlmConnection[]>; save(c: LlmConnection): Promise<LlmConnection> };
- settingsStore: { update(patch: UpdateAppSettingsInput): Promise<AppSettings> };
+ settingsStore: {
+ update(patch: UpdateAppSettingsInput): Promise<{ skippedCredentials: number }>;
+ };
credentialStore: {
- setSecret(slug: string, kind: CredentialKind, value: string): Promise<void>;
+ setSecret(entry: ExportedCredential): Promise<boolean>;
};
writeMemory(content: string): Promise<void>;
}
@@ -72,12 +79,13 @@ export async function applyConfigImport(
deps: ConfigTransferDeps,
): Promise<ConfigImportResult> {
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<string>();
+ // 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<string, LlmConnection>();
+ 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);
@@ -91,23 +99,40 @@ export async function applyConfigImport(
? reconcileConnectionAfterEnabledModelsChange(connection, connection.enabledModelIds)
: null;
await deps.connectionStore.save(selection ? { ...connection, ...selection } : connection);
- appliedConnectionSlugs.add(connection.slug);
+ credentialTargets.set(connection.slug, connection);
}
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
+ // 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) {
+ credentialTargets.set(connection.slug, connection);
+ }
}
+ 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 &&
@@ -116,17 +141,29 @@ 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.
+ const target = credentialTargets.get(entry.slug);
+ if (!target) {
skipped += 1;
continue;
}
- await deps.credentialStore.setSecret(entry.slug, entry.kind, entry.value);
- applied += 1;
+ const binding =
+ entry.connection ??
+ (hasConnectionSnapshot ? credentialConnectionBinding(target) : undefined);
+ if (!matchesCredentialConnection(binding, target)) {
+ skipped += 1;
+ continue;
+ }
+ 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') {
@@ -136,3 +173,31 @@ export async function applyConfigImport(
return result;
}
+
+export function matchesCredentialConnection(
+ binding: ExportedCredential['connection'] | undefined,
+ target: Pick<LlmConnection, 'providerType' | 'baseUrl'>,
+): boolean {
+ return (
+ binding !== undefined &&
+ binding.providerType === target.providerType &&
+ canonicalEndpoint(binding.effectiveBaseUrl) === canonicalConnectionEffectiveBaseUrl(target)
+ );
+}
+
+function credentialConnectionBinding(
+ connection: LlmConnection,
+): NonNullable<ExportedCredential['connection']> {
+ 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-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index e57314fa05..8ac6146168 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -227,9 +227,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";
@@ -1435,29 +1434,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<typeof registerRuntimeHostSettingsIpc>[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,
@@ -1496,7 +1495,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-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 29b98597c5..f30d31475b 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -427,6 +427,12 @@ export class DesktopRuntimeHostClient {
throw revisionConflict("Runtime Policy update", "workspace");
}
+ updateNetworkProxy(
+ input: OperationInput<"runtime.policy.network-proxy.update">,
+ ): Promise<OperationOutput<"runtime.policy.network-proxy.update">> {
+ return this.request("runtime.policy.network-proxy.update", input);
+ }
+
queryMemory(input: MemoryQueryInput): Promise<MemoryQueryResult> {
return this.request("memory.query", input);
}
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 51d002c19b..073283c34b 100644
--- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts
@@ -18,16 +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_REGISTRY } from '@maka/core/llm-connections';
-import type {
- ConnectionCatalogEntry,
- CredentialLocator,
+import {
+ 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';
@@ -43,12 +51,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 +71,21 @@ interface RuntimeHostConfigIpcDeps {
readonly client: DesktopRuntimeHostClient;
readonly mainWindowController: ReturnType<typeof createMainWindowController>;
readonly appVersion: string;
+ readonly settingsModule: RuntimeHostSettingsModule;
+ readonly emitConnectionsChanged: () => void;
+}
+
+interface RuntimeHostConfigGatherDeps {
+ readonly client: DesktopRuntimeHostClient;
+ readonly appVersion: string;
readonly getSettings: () => Promise<AppSettings>;
- readonly updateSettings: (
+}
+
+interface RuntimeHostConfigTransferDeps {
+ readonly client: DesktopRuntimeHostClient;
+ readonly updateSettingsForConfigImport: (
patch: UpdateAppSettingsInput,
- ) => Promise<AppSettings>;
- readonly emitConnectionsChanged: () => void;
+ ) => Promise<{ skippedCredentials: number }>;
}
export function registerRuntimeHostConfigIpc(
@@ -75,7 +98,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 +107,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 +145,29 @@ 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,
+ updateSettingsForConfigImport: settings.updateForConfigImport,
+ },
+ ),
+ ),
);
deps.emitConnectionsChanged();
return {
@@ -131,18 +181,51 @@ export function registerRuntimeHostConfigIpc(
export async function gatherRuntimeHostConfig(
categories: readonly ConfigCategory[],
- deps: RuntimeHostConfigIpcDeps,
+ deps: RuntimeHostConfigGatherDeps,
) {
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<ReturnType<typeof exportConfigurationCredentials>> = {
+ 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]),
);
@@ -150,11 +233,17 @@ export async function gatherRuntimeHostConfig(
if (selected.has('connections') && catalog) {
data.connections = projectHostConnections(catalog);
}
+ // 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')) {
- 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(exported.proxyTarget, secrets);
+ if (settingsSecrets) data.settings = settingsSecrets;
}
if (selected.has('credentials') && catalog) {
data.credentials = connectionCredentials(catalog.connections, secrets);
@@ -167,23 +256,48 @@ 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(
- deps: RuntimeHostConfigIpcDeps,
+ deps: RuntimeHostConfigTransferDeps,
): ConfigTransferDeps {
return {
connectionStore: {
@@ -192,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),
@@ -267,38 +383,35 @@ export async function saveConnection(
async function saveConnectionCredential(
client: DesktopRuntimeHostClient,
- slug: string,
- kind: string,
- value: string,
-): Promise<void> {
+ entry: ExportedCredential,
+): Promise<boolean> {
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);
@@ -307,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' } },
];
}
@@ -328,27 +447,54 @@ 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<ExportedCredential['connection']> {
+ return {
+ providerType: connection.providerType,
+ effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection),
+ };
+}
+
function restoreHostSettingsSecrets(
settings: AppSettings,
secrets: ReadonlyMap<string, string>,
-): AppSettings {
+): Record<string, unknown> {
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 +508,130 @@ function restoreHostSettingsSecrets(
};
}
+function projectHostSettingsSecrets(
+ proxyTarget: NetworkProxyCredentialTarget | undefined,
+ secrets: ReadonlyMap<string, string>,
+): Record<string, unknown> | 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 {
+ ...(proxy === undefined || proxyTarget === undefined
+ ? {}
+ : {
+ network: {
+ proxy: {
+ password: proxy,
+ credentialTarget: proxyTarget,
+ },
+ },
+ }),
+ ...(tavily === undefined
+ ? {}
+ : {
+ webSearch: {
+ providers: {
+ tavily: { apiKey: tavily },
+ },
+ },
+ }),
+ };
+}
+
+/** 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 credentialTarget = wireProxy.credentialTarget;
+ 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.',
+ );
+ }
+ if (
+ includesCredentials &&
+ typeof password === 'string' &&
+ password.length > 0 &&
+ credentialTarget === undefined
+ ) {
+ throw new Error('Proxy password import requires a target binding.');
+ }
+
+ const {
+ password: _password,
+ passwordConfigured: _passwordConfigured,
+ credential: _credential,
+ credentialTarget: _credentialTarget,
+ ...ordinaryProxy
+ } = wireProxy;
+ const proxy = {
+ ...ordinaryProxy,
+ ...(includesCredentials && passwordPresent
+ ? {
+ credential:
+ (password as string).length === 0
+ ? ({ kind: 'delete' } as const)
+ : ({
+ kind: 'replace',
+ secret: password as string,
+ ...(credentialTarget === undefined
+ ? {}
+ : {
+ expectedTarget: normalizeNetworkProxyCredentialTarget(
+ credentialTarget,
+ ),
+ }),
+ } as const),
+ }
+ : {}),
+ };
+
+ return {
+ ...bundle,
+ data: {
+ ...bundle.data,
+ settings: {
+ ...settings,
+ network: {
+ ...network,
+ proxy,
+ },
+ },
+ },
+ };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
function connectionCredentialLocator(
connection: ConnectionCatalogEntry,
): Extract<CredentialLocator, { scope: 'connection' }> | 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..369ec810e0 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,
@@ -56,6 +57,7 @@ type RuntimeHostSettingsClient = Pick<
| "queryRuntimePolicy"
| "setCredential"
| "testNetworkProxy"
+ | "updateNetworkProxy"
| "updateRuntimePolicy"
>;
@@ -76,85 +78,130 @@ export interface RuntimeHostSettingsIpcDeps {
readonly applyClientSettings: (settings: AppSettings) => Promise<void>;
}
+export type RuntimeHostSettingsModuleDeps = Omit<
+ RuntimeHostSettingsIpcDeps,
+ "ipcMain"
+>;
+
+export interface RuntimeHostSettingsModule {
+ get(): Promise<RuntimeHostAppSettings>;
+ update(patch: UpdateAppSettingsInput): Promise<RuntimeHostAppSettings>;
+ testNetworkProxy(input?: TestProxyInput): Promise<SettingsTestResult>;
+}
+
+export interface RuntimeHostSettingsExclusiveAccess {
+ get(): Promise<RuntimeHostAppSettings>;
+ update(patch: UpdateAppSettingsInput): Promise<RuntimeHostAppSettings>;
+ updateForConfigImport(
+ patch: UpdateAppSettingsInput,
+ ): Promise<RuntimeHostSettingsImportResult>;
+}
+
+export interface RuntimeHostSettingsImportResult {
+ readonly settings: RuntimeHostAppSettings;
+ readonly skippedCredentials: number;
+}
+
+type RuntimeHostSettingsExclusiveRunner = <T>(
+ operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise<T>,
+) => Promise<T>;
+
+const exclusiveRunners = new WeakMap<
+ RuntimeHostSettingsModule,
+ RuntimeHostSettingsExclusiveRunner
+>();
+
+interface RuntimeHostSettingsIpcRegistrationDeps {
+ readonly ipcMain: ReconnectableReadIpcMain;
+ readonly module: RuntimeHostSettingsModule;
+}
+
+export function createRuntimeHostSettingsModule(
+ deps: RuntimeHostSettingsModuleDeps,
+): RuntimeHostSettingsModule {
+ let lane: Promise<void> = Promise.resolve();
+
+ function enqueue<T>(operation: () => Promise<T>): Promise<T> {
+ const result = lane.then(operation, operation);
+ lane = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ }
+
+ const module: RuntimeHostSettingsModule = {
+ get: () => enqueue(() => loadRuntimeHostSettingsWithoutLane(deps)),
+ update: (patch) =>
+ enqueue(() =>
+ updateRuntimeHostSettingsForImportWithoutLane(deps, patch).then(
+ (result) => result.settings,
+ ),
+ ),
+ testNetworkProxy: (input = {}) =>
+ enqueue(() => testNetworkProxyWithoutLane(deps.client, input)),
+ };
+ exclusiveRunners.set(module, (operation) =>
+ enqueue(() =>
+ operation({
+ get: () => loadRuntimeHostSettingsWithoutLane(deps),
+ update: (patch) =>
+ updateRuntimeHostSettingsForImportWithoutLane(deps, patch).then(
+ (result) => result.settings,
+ ),
+ updateForConfigImport: (patch) =>
+ updateRuntimeHostSettingsForImportWithoutLane(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<T>(
+ module: RuntimeHostSettingsModule,
+ operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise<T>,
+): Promise<T> {
+ 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 = deps.module;
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<UpdateAppSettingsResult> => {
- const settings = await updateRuntimeHostSettings(deps, patch);
+ ): Promise<UpdateAppSettingsResult<RuntimeHostAppSettings>> => {
+ const settings = await module.update(patch);
return buildSettingsUpdateResult(settings, patch);
},
);
}
-export async function updateRuntimeHostSettings(
- deps: RuntimeHostSettingsIpcDeps,
- patch: UpdateAppSettingsInput,
-): Promise<AppSettings> {
- 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[],
): 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,
@@ -167,13 +214,48 @@ function toRuntimeHostProxyPolicy(
};
}
-function credentialOverride(value: string | undefined): string | undefined {
- return !value || value === SENSITIVE_PLACEHOLDER ? undefined : value;
+async function testNetworkProxyWithoutLane(
+ client: RuntimeHostSettingsClient,
+ input: TestProxyInput,
+): Promise<SettingsTestResult> {
+ const current = (await client.queryRuntimePolicy()).policy.networkProxy;
+ const candidate = input.proxy
+ ? toRuntimeHostProxyPolicy(input.proxy, current.autoBypassDomains)
+ : undefined;
+ const result = await client.testNetworkProxy({
+ ...(candidate ? { networkProxy: candidate } : {}),
+ ...(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,
+ },
+ };
}
-export async function loadRuntimeHostSettings(
- deps: RuntimeHostSettingsIpcDeps,
-): Promise<AppSettings> {
+async function loadRuntimeHostSettingsWithoutLane(
+ deps: RuntimeHostSettingsModuleDeps,
+): Promise<RuntimeHostAppSettings> {
const [local, runtimePolicy, proxyCredential, webSearchCredential] =
await Promise.all([
deps.settingsStore.get(),
@@ -189,7 +271,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 +294,23 @@ export async function loadRuntimeHostSettings(
};
}
+async function updateRuntimeHostSettingsForImportWithoutLane(
+ deps: RuntimeHostSettingsModuleDeps,
+ patch: UpdateAppSettingsInput,
+): Promise<RuntimeHostSettingsImportResult> {
+ validateProxyPatch(patch.network?.proxy);
+ 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 {
+ settings: await loadRuntimeHostSettingsWithoutLane(deps),
+ skippedCredentials,
+ };
+}
+
function projectWebSearchCredential(
local: AppSettings,
credential: CredentialStatus | null,
@@ -234,26 +333,13 @@ function projectWebSearchCredential(
};
}
-async function applyHostPatch(
+async function applyHostPatchWithoutLane(
client: RuntimeHostSettingsClient,
patch: UpdateAppSettingsInput,
-): Promise<void> {
+): Promise<number> {
+ let skippedCredentials = 0;
if (patch.network?.proxy) {
- const proxy = patch.network.proxy;
- await client.updateRuntimePolicy((policy) => ({
- kind: "set_network_proxy",
- value: { ...policy.networkProxy, ...withoutSecret(proxy) },
- }));
- if (proxy.authEnabled === false)
- 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);
- }
+ skippedCredentials += await updateNetworkProxy(client, patch.network.proxy);
}
if (
patch.personalization?.displayName !== undefined ||
@@ -324,6 +410,45 @@ async function applyHostPatch(
value: patch.subagents!,
}));
}
+ return skippedCredentials;
+}
+
+async function updateNetworkProxy(
+ client: RuntimeHostSettingsClient,
+ patch: NonNullable<NonNullable<UpdateAppSettingsInput["network"]>["proxy"]>,
+): Promise<number> {
+ 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 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");
+ }
+ throw new Error("Runtime Host proxy credential changed while Desktop updated it");
}
async function mergePolicy<
@@ -391,9 +516,38 @@ async function deleteCredential(
throw new Error("Credential kept changing while Desktop removed it");
}
-function withoutSecret(
+function withoutCredential(
patch: NonNullable<NonNullable<UpdateAppSettingsInput["network"]>["proxy"]>,
): Partial<RuntimePolicy["networkProxy"]> {
- 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<UpdateAppSettingsInput["network"]>["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..df8952d38b 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,
@@ -50,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)
@@ -64,72 +70,20 @@ 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,
- ...(patch.network?.proxy?.password === SENSITIVE_PLACEHOLDER
- ? {
- network: {
- ...patch.network,
- proxy: {
- ...patch.network.proxy,
- password: current.network.proxy.password,
- },
- },
- }
- : {}),
- ...(botChannels
- ? {
- botChat: {
- ...patch.botChat,
- channels: botChannels,
- },
- }
- : {}),
- };
-}
-
+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 +137,7 @@ export function stripSettingsSecretsForExport(
): Record<string, unknown> {
const proxy = { ...settings.network.proxy } as Record<string, unknown>;
delete proxy.password;
+ delete proxy.passwordConfigured;
const channels: Record<string, unknown> = {};
for (const [provider, channel] of Object.entries(settings.botChat.channels)) {
@@ -209,6 +164,14 @@ export function stripSettingsSecretsForExport(
};
}
+export function buildSettingsUpdateResult(
+ settings: RuntimeHostAppSettings,
+ patch: UpdateAppSettingsInput,
+): UpdateAppSettingsResult<RuntimeHostAppSettings>;
+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 33949c85e0..1b0183f1c4 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,
@@ -1425,9 +1426,9 @@ export interface MakaBridge {
};
settings: {
getClient(): Promise<AppSettings>;
- get(host?: DesktopRuntimeHostRef): Promise<AppSettings>;
+ get(host?: DesktopRuntimeHostRef): Promise<RuntimeHostAppSettings>;
updateClient(patch: UpdateAppSettingsInput): Promise<UpdateAppSettingsResult>;
- update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise<UpdateAppSettingsResult>;
+ update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise<UpdateAppSettingsResult<RuntimeHostAppSettings>>;
subscribeClientChanged(handler: () => void): () => void;
subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void;
testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise<SettingsTestResult>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 0876bccc7b..11a663f495 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -110,6 +110,7 @@ import type {
AppIconChoice,
AppIconTarget,
AppSettings,
+ RuntimeHostAppSettings,
SettingsTestResult,
UpdateAppSettingsInput,
UpdateAppSettingsResult,
@@ -3045,13 +3046,13 @@ const makaBridge = {
getClient(): Promise<AppSettings> {
return ipcRenderer.invoke('settings:client:get');
},
- get(host?: DesktopRuntimeHostRef): Promise<AppSettings> {
+ get(host?: DesktopRuntimeHostRef): Promise<RuntimeHostAppSettings> {
return invokeSelectedRuntimeHost(host, 'settings:get');
},
updateClient(patch: UpdateAppSettingsInput): Promise<UpdateAppSettingsResult> {
return ipcRenderer.invoke('settings:client:update', patch);
},
- update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise<UpdateAppSettingsResult> {
+ update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise<UpdateAppSettingsResult<RuntimeHostAppSettings>> {
return invokeSelectedRuntimeHost(host, 'settings:update', patch);
},
subscribeClientChanged(handler: () => void): () => void {
diff --git a/apps/desktop/src/renderer/features/network-proxy/controller/use-proxy-password-draft.ts b/apps/desktop/src/renderer/features/network-proxy/controller/use-proxy-password-draft.ts
new file mode 100644
index 0000000000..6db03e5f8d
--- /dev/null
+++ b/apps/desktop/src/renderer/features/network-proxy/controller/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 "../model/proxy-password-draft.js";
+
+export function useProxyPasswordDraft(
+ save: (secret: string) => Promise<void>,
+): ProxyPasswordDraft {
+ const saveRef = useRef(save);
+ saveRef.current = save;
+ const draftRef = useRef<ProxyPasswordDraft | null>(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/src/renderer/features/network-proxy/index.ts b/apps/desktop/src/renderer/features/network-proxy/index.ts
new file mode 100644
index 0000000000..ef56eb868b
--- /dev/null
+++ b/apps/desktop/src/renderer/features/network-proxy/index.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 {
+ 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<void>;
+ children(draft: ProxyPasswordDraft): ReactNode;
+}) {
+ return props.children(useProxyPasswordDraft(props.save));
+}
diff --git a/apps/desktop/src/renderer/features/network-proxy/model/proxy-password-draft.ts b/apps/desktop/src/renderer/features/network-proxy/model/proxy-password-draft.ts
new file mode 100644
index 0000000000..188919efc9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/network-proxy/model/proxy-password-draft.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 interface ProxyPasswordDraft {
+ readonly value: string;
+ readonly pending: boolean;
+ edit(value: string): void;
+ commit(): Promise<void>;
+ cancel(): void;
+ subscribe(listener: () => void): () => void;
+}
+
+export async function runAfterProxyPasswordCommit<T>(
+ draft: Pick<ProxyPasswordDraft, "commit">,
+ operation: () => Promise<T>,
+): Promise<T> {
+ await draft.commit();
+ return operation();
+}
+
+interface ActiveSave {
+ readonly secret: string;
+ readonly promise: Promise<void>;
+}
+
+/** Owns the write-only password draft and hides save de-duplication from UI callers. */
+export function createProxyPasswordDraft(
+ save: (secret: string) => Promise<void>,
+): ProxyPasswordDraft {
+ let value = "";
+ let active: ActiveSave | undefined;
+ const listeners = new Set<() => void>();
+
+ function notify(): void {
+ for (const listener of listeners) listener();
+ }
+
+ function commit(): Promise<void> {
+ 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<void>;
+ 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/features/network-proxy/testing.ts b/apps/desktop/src/renderer/features/network-proxy/testing.ts
new file mode 100644
index 0000000000..35853bba89
--- /dev/null
+++ b/apps/desktop/src/renderer/features/network-proxy/testing.ts
@@ -0,0 +1,23 @@
+/*
+ * 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,
+} from "./model/proxy-password-draft.js";
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 [...]
proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTe [...]
+ passwordSavedPlaceholder: '密码已保存;输入新密码以替换',
},
about: {
loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], [...]
@@ -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', worksp [...]
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.', save [...]
+ 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 securit [...]
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 42b0a0dd17..1bf53b36d9 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 { THINKING_LEVELS, type ThinkingLevel } from '@maka/core/model-thinking';
@@ -38,7 +39,6 @@ import type {
IdentifiedLlmConnection,
ProjectedLlmConnection,
} from '@maka/core/llm-connections';
-import type { TestProxyInput } from "@maka/core/settings/network-settings";
import { buildChatModelChoices } from "@maka/core/chat-model-choice";
import {
Button,
@@ -63,6 +63,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 {
+ 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";
@@ -748,29 +754,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<NetworkProxySettings>(
+ } = useOptimisticSettingsDraft<RuntimeHostNetworkProxySettings>(
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,
},
);
@@ -779,12 +791,27 @@ function NetworkProxySection(props: {
return update(patch);
}
- async function testProxy() {
+ async function saveProxyPassword(secret: string) {
+ try {
+ await props.onUpdate({
+ network: {
+ proxy: { credential: { kind: "replace", secret } },
+ },
+ });
+ } catch (error) {
+ reportNetworkSaveError(error);
+ throw error;
+ }
+ }
+
+ async function testProxy(passwordDraft: ProxyPasswordDraft) {
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);
@@ -816,7 +843,9 @@ function NetworkProxySection(props: {
}
return (
- <>
+ <NetworkProxyPasswordDraft save={saveProxyPassword}>
+ {(passwordDraft) => (
+ <>
<SettingsRow
label={copy.proxy}
description={copy.proxyHelp}
@@ -877,7 +906,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();
+ });
+ }}
/>
}
/>
@@ -892,8 +930,22 @@ function NetworkProxySection(props: {
isDisabled={!props.isInteractive}
/>
<PasswordInput
- value={proxyDraft.password}
- onChange={(next) => 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") return;
+ event.preventDefault();
+ event.stopPropagation();
+ passwordDraft.cancel();
+ }}
+ hasCopyAction={false}
+ placeholder={
+ proxyDraft.passwordConfigured
+ ? copy.passwordSavedPlaceholder
+ : undefined
+ }
label={copy.password}
isDisabled={!props.isInteractive}
/>
@@ -927,13 +979,15 @@ function NetworkProxySection(props: {
variant="primary"
isLoading={testing}
isDisabled={!props.isInteractive}
- onClick={() => void testProxy()}
+ onClick={() => void testProxy(passwordDraft)}
label={copy.testCurrent}
/>
</SettingsActions>
</>
)}
- </>
+ </>
+ )}
+ </NetworkProxyPasswordDraft>
);
}
@@ -949,8 +1003,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..6629e9d3fa 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,
@@ -63,7 +69,10 @@ export function PasswordInput(props: {
isRequired?: boolean;
isOptional?: boolean;
isDisabled?: boolean;
- onBlur?(): void;
+ onFocusExit?(): void;
+ onEnter?(): void;
+ onKeyDown?(event: KeyboardEvent<HTMLInputElement>): void;
+ hasCopyAction?: boolean;
hasAutoFocus?: boolean;
}) {
const copy = getSettingsPreferencesCopy(useUiLocale()).password;
@@ -125,12 +134,29 @@ export function PasswordInput(props: {
isRequired={props.isRequired}
isOptional={props.isOptional}
status={props.status}
+ onBlurCapture={(event) => {
+ const destination = event.relatedTarget;
+ if (!destination && !event.currentTarget.ownerDocument.hasFocus()) {
+ return;
+ }
+ if (
+ destination &&
+ event.currentTarget.contains(destination as Node)
+ ) {
+ return;
+ }
+ props.onFocusExit?.();
+ }}
>
<TextInput
type={visible ? 'text' : 'password'}
value={props.value}
onChange={(value) => props.onChange(value)}
- onBlur={props.onBlur}
+ 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
@@ -141,7 +167,7 @@ export function PasswordInput(props: {
/>
{/* InputGroupText: the sanctioned addon segment — bare IconButtons break the group's caps. */}
<InputGroupText>
- {props.value && !props.isDisabled && (
+ {(props.hasCopyAction ?? true) && props.value && !props.isDisabled && (
<IconButton
variant="ghost"
size="sm"
diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts
index 278cc98f99..b06a09041e 100644
--- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts
+++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts
@@ -17,9 +17,8 @@
* under the License.
*/
-import type { AppSettings } from '@maka/core/settings';
+import type { AppSettings, RuntimeHostAppSettings } from '@maka/core/settings';
import type {
- IdentifiedLlmConnection,
ProjectedLlmConnection,
} from '@maka/core/llm-connections';
import type {
@@ -39,8 +38,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(
@@ -61,7 +60,7 @@ export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string {
export function createSettingsSnapshotCache(): SettingsSnapshotCache {
let client: AppSettings | undefined;
let runtimeHostCatalog: DesktopRuntimeHostProfileSnapshot | undefined;
- const runtimeHostSettings = new Map<string, AppSettings>();
+ const runtimeHostSettings = new Map<string, RuntimeHostAppSettings>();
const runtimeHostConnections = new Map<string, RuntimeHostConnectionsSnapshot>();
return {
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index 73517879bf..d6bae697cd 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,
@@ -292,7 +293,7 @@ function SettingsSurfaceContent(
initialClientSettings ?? defaultSettings,
);
const [runtimeHostSettings, setRuntimeHostSettings] = useState<
- SettingsResourceState<AppSettings>
+ SettingsResourceState<RuntimeHostAppSettings>
>(() => createSettingsResourceState(
initialRuntimeHostKey,
initialRuntimeHostKey
@@ -635,7 +636,10 @@ function SettingsSurfaceContent(
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/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 5195fe534f..112167a433 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,
@@ -709,11 +710,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',
@@ -734,7 +753,7 @@ function seedGeneralTwoHostSnapshotCache(cache: SettingsSnapshotCache): void {
}
let storyClientSettings = createDefaultSettings();
-let storyRuntimeHostSettings = createDefaultSettings();
+let storyRuntimeHostSettings = storyRuntimeSettings();
const makaBridge = {
runtimeHostProfiles: {
@@ -780,7 +799,16 @@ const makaBridge = {
return { settings: storyClientSettings };
},
update: async (patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult> => {
- 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/docs/windows-test-inventory.md b/docs/windows-test-inventory.md
index 5e96b5f86d..d13667bfd7 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 | 27 |
-| portable-candidate | 14 |
+| portable-candidate | 18 |
| platform-contract | 31 |
-Total Windows-excluded declarations: **72**
+Total Windows-excluded declarations: **76**
## Inventory
@@ -91,6 +91,10 @@ 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'` |
| 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` |
diff --git a/packages/core/src/__tests__/settings.test.ts b/packages/core/src/__tests__/settings.test.ts
index 2c9e54e2ec..acc1106af7 100644
--- a/packages/core/src/__tests__/settings.test.ts
+++ b/packages/core/src/__tests__/settings.test.ts
@@ -344,3 +344,38 @@ describe('app icon on upgrade', () => {
assert.strictEqual(migrated.appearance.appIconDark, undefined);
});
});
+
+test('proxy credentials never enter persisted settings', () => {
+ const defaults = createDefaultSettings();
+ assert.strictEqual('password' in defaults.network.proxy, false);
+ assert.strictEqual('passwordConfigured' in defaults.network.proxy, 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);
+
+ assert.strictEqual(merged.network.proxy.host, '10.0.0.2');
+ assert.strictEqual('credential' in merged.network.proxy, false);
+ assert.strictEqual('password' in merged.network.proxy, false);
+ assert.strictEqual('passwordConfigured' in merged.network.proxy, false);
+
+ const normalized = normalizeSettings({
+ network: {
+ proxy: {
+ password: 'legacy-secret',
+ passwordConfigured: true,
+ credential: { kind: 'delete' },
+ },
+ },
+ });
+ assert.strictEqual('credential' in normalized.network.proxy, false);
+ assert.strictEqual('password' in normalized.network.proxy, false);
+ assert.strictEqual('passwordConfigured' in normalized.network.proxy, false);
+});
diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts
index 4d65b75c0f..336b79652e 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,7 +51,9 @@ export {
} from './runtime-policy/domain-codec.js';
export {
decodeCanonicalRuntimePolicy,
+ normalizeNetworkProxyCredentialTarget,
decodeRuntimePolicyV2,
+ normalizeNetworkProxyUpdate,
normalizeRuntimePolicyMutation,
} from './runtime-policy/policy-codec.js';
export {
@@ -56,6 +66,7 @@ export {
decodeCanonicalConnectionBaseUrl,
decodeCanonicalConnectionCatalogEntry,
decodeConnectionModelId,
+ decodeConnectionCredentialTarget,
decodeRelayModelProfilesTable,
decodeConnectionModel,
decodeConnectionName,
@@ -69,6 +80,8 @@ export {
normalizeConnectionCatalogEntryUpdate,
normalizeConnectionCatalogEntryUpdateForProvider,
normalizeConnectionModelDiscoveryResult,
+ canonicalConnectionEffectiveBaseUrl,
+ connectionCredentialTarget,
normalizeCreateCatalogConnectionInput,
normalizeRemoveCatalogConnectionInput,
normalizeSetDefaultConnectionTargetInput,
@@ -186,6 +199,44 @@ export type MutateRuntimePolicyResult =
| { readonly kind: 'committed'; readonly snapshot: RuntimePolicySnapshot }
| RevisionConflict;
+export type NetworkProxyCredentialUpdate =
+ | { readonly kind: 'keep' }
+ | {
+ readonly kind: 'replace';
+ readonly secret: string;
+ readonly expectedTarget?: NetworkProxyCredentialTarget;
+ }
+ | { 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: 'proxy_target_mismatch';
+ readonly expected: NetworkProxyCredentialTarget;
+ readonly actual: NetworkProxyCredentialTarget;
+ }
+ | {
+ readonly kind: 'credential_stale';
+ readonly expected: CredentialVersionBasis | null;
+ readonly actual: CredentialVersionBasis | null;
+ };
+
export function createDefaultRuntimePolicy(): RuntimePolicy {
return {
networkProxy: {
@@ -284,6 +335,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;
@@ -390,6 +447,7 @@ export interface CredentialVaultSnapshot {
export interface SetCredentialInput {
readonly locator: CredentialLocator;
readonly expected: (CredentialIdentity & { readonly revision: Revision }) | null;
+ readonly expectedConnection?: ConnectionCredentialTarget;
readonly secret: string;
}
@@ -400,6 +458,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 39524ae772..67c5796987 100644
--- a/packages/core/src/runtime-policy/connection-catalog-codec.ts
+++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts
@@ -20,6 +20,7 @@
import {
isModelModality,
isRelayProviderType,
+ effectiveBaseUrl,
PROVIDER_REGISTRY,
providerDefaultsOf,
validateSlug,
@@ -39,6 +40,7 @@ import {
} from '../model-thinking.js';
import type {
ConnectionCatalogEntry,
+ ConnectionCredentialTarget,
ConnectionCatalogEntryDraft,
ConnectionCatalogEntryUpdate,
ConnectionModel,
@@ -486,6 +488,67 @@ export function decodeConnectionVersionBasis(value: unknown): ConnectionVersionB
};
}
+export function canonicalConnectionEffectiveBaseUrl(
+ connection: Pick<ConnectionCatalogEntry, 'providerType' | 'baseUrl'>,
+): 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 442f19eb57..3462dbc01b 100644
--- a/packages/core/src/runtime-policy/policy-codec.ts
+++ b/packages/core/src/runtime-policy/policy-codec.ts
@@ -23,9 +23,16 @@ import { normalizeSubagentSettings } from '../subagent-settings.js';
import type {
AgentRuntimeSettingsPatch,
MutateRuntimePolicyInput,
+ NetworkProxyCredentialUpdate,
+ NetworkProxyCredentialTarget,
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 +81,99 @@ 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', 'expectedTarget'],
+ ['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', '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 001bde9aa6..b6d2daccec 100644
--- a/packages/core/src/settings.ts
+++ b/packages/core/src/settings.ts
@@ -99,11 +99,46 @@ export interface NetworkProxySettings {
port: number;
authEnabled: boolean;
username: string;
- password: string;
bypassList: string[];
autoBypassDomains: string[];
}
+export interface NetworkProxyCredentialTarget {
+ readonly protocol: ProxyProtocol;
+ readonly host: string;
+ readonly port: number;
+ readonly username: string;
+}
+
+export function networkProxyCredentialTarget(
+ proxy: Pick<NetworkProxySettings, 'protocol' | 'host' | 'port' | 'username'>,
+): NetworkProxyCredentialTarget {
+ return {
+ protocol: proxy.protocol,
+ host: proxy.host.trim().toLowerCase(),
+ port: proxy.port,
+ username: proxy.username,
+ };
+}
+
+export type NetworkProxyCredentialOperation =
+ | {
+ kind: 'replace';
+ secret: string;
+ expectedTarget?: NetworkProxyCredentialTarget;
+ }
+ | { kind: 'delete' };
+
+/** A write-only proxy patch. Credential operations are never persisted. */
+export type NetworkProxySettingsPatch = Partial<NetworkProxySettings> & {
+ 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 +587,12 @@ export interface AppSettings {
subagents: SubagentSettings;
}
+export interface RuntimeHostAppSettings extends Omit<AppSettings, 'network'> {
+ network: {
+ proxy: RuntimeHostNetworkProxySettings;
+ };
+}
+
export interface UsageRequestLog {
id: string;
ts: number;
@@ -642,6 +683,7 @@ export type SettingsTestResultCode =
| 'proxy_reachable'
| 'proxy_disabled'
| 'proxy_configuration_missing'
+ | 'proxy_credential_missing'
| 'proxy_timeout'
| 'proxy_http_error'
| 'proxy_unreachable'
@@ -653,7 +695,7 @@ export type SettingsTestResultCode =
export type UpdateAppSettingsInput = Partial<{
network: Partial<{
- proxy: Partial<NetworkProxySettings>;
+ proxy: NetworkProxySettingsPatch;
}>;
botChat: BotChatSettingsPatch;
usage: Partial<UsageSettings>;
@@ -681,8 +723,8 @@ export interface UpdateAppSettingsWarnings {
personalization?: PersonalizationSettingsWarning[];
}
-export interface UpdateAppSettingsResult {
- settings: AppSettings;
+export interface UpdateAppSettingsResult<TSettings extends AppSettings = AppSettings> {
+ settings: TSettings;
warnings?: UpdateAppSettingsWarnings;
}
@@ -706,7 +748,6 @@ export function createDefaultSettings(): AppSettings {
port: 7890,
authEnabled: false,
username: '',
- password: '',
bypassList: ['metaso.cn', 'baidu.com'],
autoBypassDomains: DEFAULT_PROXY_BYPASS_DOMAINS,
},
@@ -763,6 +804,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 +820,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/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<ProxySettings, 'password'> {
authEnabled?: boolean;
}
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index 3bbe698872..1989f4b504 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -251,6 +251,21 @@ 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 > 83);
+ });
+
+ test('publishes a new compatibility epoch for bound configuration credentials', () => {
+ 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
@@ -899,6 +914,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(
@@ -982,6 +1087,107 @@ 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 = {
+ 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',
+ expectedTarget: {
+ protocol: 'http' as const,
+ host: '127.0.0.1',
+ port: 7897,
+ username: 'proxy-user',
+ },
+ },
+ };
+ 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.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({
+ 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 b560553f37..84231b07ea 100644
--- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts
@@ -29,7 +29,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 { resolveConnectionModelCatalog } from '@maka/core/model-catalog';
import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '@maka/runtime/test-only/fake-backend';
import { type MakaToolContext } from '@maka/runtime/tool-runtime';
@@ -467,6 +470,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(
@@ -812,6 +888,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<T>(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 7b7c8a2cf8..bc5f27e602 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -100,7 +100,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 = 92 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 93 as const;
+// 93: 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.
// 92: Owners can query their complete pending Session Turn-request inbox.
// 91: Host status publishes the live Direct peer endpoint so newly issued
// connection invitations do not preserve stale startup routes.
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/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index f826da5017..0a03ab137e 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -298,6 +298,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 b121bb5bc4..7f7f877f1b 100644
--- a/packages/runtime-host/src/protocol/runtime-policy.ts
+++ b/packages/runtime-host/src/protocol/runtime-policy.ts
@@ -41,6 +41,8 @@ import {
normalizeDeleteCredentialInput,
normalizeRemoveCatalogConnectionInput,
normalizeOptionalRequestBodyOverlay,
+ normalizeNetworkProxyUpdate,
+ normalizeNetworkProxyCredentialTarget,
normalizeRequestHeaderUpdates,
normalizeRuntimePolicyMutation,
normalizeSetCredentialInput,
@@ -59,10 +61,12 @@ import {
type CredentialVersionBasis,
type DeleteCredentialInput,
type MutateRuntimePolicyInput,
+ type NetworkProxyCredentialTarget,
type RemoveCatalogConnectionInput,
type RequestHeaderUpdate,
type RevisionConflict,
type RuntimePolicySnapshot,
+ type UpdateNetworkProxyInput,
type SetCredentialInput,
type SetDefaultConnectionTargetInput,
type UpdateCatalogConnectionInput,
@@ -107,6 +111,20 @@ 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
+ | {
+ readonly kind: 'proxy_target_mismatch';
+ readonly expected: NetworkProxyCredentialTarget;
+ readonly actual: NetworkProxyCredentialTarget;
+ }
+ | CredentialStale;
export type ConnectionCatalogCursor =
| { readonly connectionIndex: number; readonly part: 'connection' }
@@ -244,6 +262,7 @@ export type CredentialVaultQueryResult =
export type SetCredentialResult =
| CredentialCommitted
| { readonly kind: 'connection_not_found' }
+ | ConnectionStale
| CredentialStale;
export type DeleteCredentialResult =
| CredentialCommitted
@@ -304,6 +323,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,
@@ -449,6 +479,51 @@ 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);
+ 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');
+}
+
function decodeCatalogQueryInput(value: unknown): ConnectionCatalogQueryInput {
const item = requireRecord(value, 'connection catalog query input');
if (item.kind === 'start') {
@@ -886,6 +961,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<OperationOutcome<'configuration.credentials.export'>> {
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/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/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts
index 2133cf9bfe..aec72b6ffb 100644
--- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts
+++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts
@@ -97,6 +97,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),
@@ -145,6 +146,33 @@ export class HostRuntimePolicyCoordinator {
});
}
+ async #updateNetworkProxy(
+ input: Parameters<RuntimePolicyOperationHandlerMap['runtime.policy.network-proxy.update']>[0],
+ ): Promise<OperationOutcome<'runtime.policy.network-proxy.update'>> {
+ 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<OperationOutcome<'connection.catalog.query'>> {
@@ -241,7 +269,11 @@ export class HostRuntimePolicyCoordinator {
): Promise<OperationOutcome<'credential.vault.set'>> {
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) =>
@@ -259,6 +291,9 @@ export class HostRuntimePolicyCoordinator {
): Promise<OperationOutcome<'credential.vault.delete'>> {
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 d6a5ae1d33..2c3b428b53 100644
--- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts
+++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts
@@ -2122,6 +2122,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_REGISTRY.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'
@@ -3050,6 +3131,383 @@ 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('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'
+ ? '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'
+ ? 'POSIX file handles are required to inject persistence failures'
+ : false,
+ }, async () => {
+ 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('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'
+ ? '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: 'disable-order.proxy.internal',
+ port: 7897,
+ authEnabled: true,
+ username: 'disable-order-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 === 3) throw new Error('injected proxy credential deletion 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('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 () => {
await withInteractiveOwner(async ({ stores }) => {
const policy = await stores.runtimePolicy.mutate({
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<string, unknown> };
+ };
+
+ 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/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts
index cf4b882194..f3c4a97689 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,
@@ -236,7 +237,16 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic
delete: (input) => coordinator.deleteCredential(input),
},
operations: {
- exportCredentialMaterial: (locator) => coordinator.exportCredentialMaterial(locator),
+ updateNetworkProxy: (input) => coordinator.updateNetworkProxy(input),
+ 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 7669f9f7d1..affb34997d 100644
--- a/packages/storage/src/runtime-policy/coordinator.ts
+++ b/packages/storage/src/runtime-policy/coordinator.ts
@@ -18,9 +18,12 @@
*/
import { randomUUID } from 'node:crypto';
+import { isDeepStrictEqual } from 'node:util';
import {
CONNECTION_CATALOG_MAX_CONNECTIONS,
decodeConnectionModelId,
+ connectionCredentialTarget,
+ decodeConnectionCredentialTarget,
decodeConnectionSlug,
decodeProviderType,
decodeRuntimePolicyEntityId,
@@ -35,8 +38,11 @@ import {
RequestCustomizationValidationError,
normalizeCredentialSecret,
normalizeCatalogConnectionBaseUrl,
+ normalizeNetworkProxyUpdate,
+ networkProxyCredentialTarget,
type ConnectionCatalogEntry,
type ConnectionCatalogSnapshot,
+ type ConnectionCredentialTarget,
type ConnectionVersionBasis,
type ConnectionModelDiscoveryResult,
type ConnectionTestSummary,
@@ -54,6 +60,8 @@ import {
type MigrateSystemSeedInput,
type SetDefaultConnectionTargetInput,
type UpdateCatalogConnectionInput,
+ type UpdateNetworkProxyInput,
+ type UpdateNetworkProxyResult,
} from '@maka/core/runtime-policy';
import {
applyModelFactOverridesToConnection,
@@ -98,6 +106,7 @@ import {
commitOutcomeUnknown,
decodeConnectionInput,
decodeCredentialInput,
+ decodePolicyInput,
RuntimePolicyStoreError,
} from './errors.js';
import {
@@ -105,6 +114,7 @@ import {
connectionRequestHeadersLocator,
type CredentialStatusQueryResult,
type BeginConnectionTestResult,
+ type BoundCredentialMaterialExportResult,
type BeginModelFetchResult,
type BeginInteractiveOAuthLoginResult,
type CompareAndSetOAuthCredentialInput,
@@ -343,6 +353,67 @@ export class RuntimePolicyCoordinator {
});
}
+ updateNetworkProxy(rawInput: UpdateNetworkProxyInput): Promise<UpdateNetworkProxyResult> {
+ 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;
+
+ 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)) {
+ return deepFreeze({
+ kind: 'credential_stale' as const,
+ expected: input.expectedCredential,
+ actual: existing ? credentialBasis(existing) : null,
+ });
+ }
+ // 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(),
+ 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');
+ }
+ }
+
+ return this.applyNetworkProxyUpdate(root, input);
+ });
+ }
+
createConnection(input: CreateCatalogConnectionInput) {
return this.inLane(async (root) =>
this.projectCatalogMutation(root, await this.catalog.create(root, input)),
@@ -431,6 +502,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,
@@ -776,15 +860,63 @@ export class RuntimePolicyCoordinator {
exportCredentialMaterial(
rawLocator: CredentialLocator,
- ): Promise<RuntimePolicyCredentialMaterial | null> {
+ ): Promise<RuntimePolicyCredentialMaterial | null>;
+ exportCredentialMaterial(
+ rawLocator: CredentialLocator,
+ rawExpectedConnection: ConnectionCredentialTarget,
+ ): Promise<BoundCredentialMaterialExportResult>;
+ exportCredentialMaterial(
+ rawLocator: CredentialLocator,
+ rawExpectedConnection?: ConnectionCredentialTarget,
+ ): Promise<RuntimePolicyCredentialMaterial | null | BoundCredentialMaterialExportResult> {
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;
});
}
@@ -1768,6 +1900,95 @@ export class RuntimePolicyCoordinator {
}
}
+ private async applyNetworkProxyUpdate(
+ root: string,
+ input: UpdateNetworkProxyInput,
+ ): Promise<Extract<UpdateNetworkProxyResult, { readonly kind: 'committed' }>> {
+ const policy = await this.policy.read(root);
+ const vault = await this.vault.read(root);
+ const locator = networkProxyCredentialLocator();
+ const existing = findCredential(vault, locator);
+ const credentialChanged =
+ 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(input.networkProxy),
+ );
+ 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<void> => {
+ 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;
+ }
+ };
+
+ const commitPolicy = async (): Promise<void> => {
+ 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;
+ };
+
+ // 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();
+ }
+ } 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({
+ kind: 'committed' as const,
+ snapshot,
+ credentialStatus: credentialStatus(finalVault, locator),
+ });
+ }
+
private async applyInteractiveOAuthEnrollment(
root: string,
intent: InteractiveOAuthEnrollmentIntent,
@@ -2027,6 +2248,13 @@ function hideStaleModelFactsVerification(
};
}
+function matchesCredentialExpectation(
+ actual: ReturnType<typeof findCredential>,
+ 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/operations.ts b/packages/storage/src/runtime-policy/operations.ts
index b27bf35e09..0a6afc6fb3 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,9 @@ import type {
CredentialStatus,
CredentialVersionBasis,
RuntimePolicy,
+ NetworkProxyCredentialTarget,
+ UpdateNetworkProxyInput,
+ UpdateNetworkProxyResult,
RequestHeaderUpdate,
SavedRequestHeaders,
} from '@maka/core/runtime-policy';
@@ -41,8 +46,20 @@ export type ConnectionEffectChangedDomain = 'connection' | 'credential' | 'netwo
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;
@@ -351,9 +368,14 @@ export type ReplaceConnectionRequestHeadersResult =
| { readonly kind: 'connection_not_found' };
export interface RuntimePolicyOperationCoordinator {
+ updateNetworkProxy(input: UpdateNetworkProxyInput): Promise<UpdateNetworkProxyResult>;
exportCredentialMaterial(
locator: CredentialLocator,
): Promise<RuntimePolicyCredentialMaterial | null>;
+ exportCredentialMaterial(
+ locator: CredentialLocator,
+ expectedConnection: ConnectionCredentialTarget,
+ ): Promise<BoundCredentialMaterialExportResult>;
getConnectionRequestHeaders(connectionId: string): Promise<SavedRequestHeaders | null>;
replaceConnectionRequestHeaders(
connectionId: string,
diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts
index 39b0377d1b..8b892e30df 100644
--- a/packages/storage/src/settings-store.ts
+++ b/packages/storage/src/settings-store.ts
@@ -19,7 +19,7 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
-import type { AppSettings, SettingsTestResult, UpdateAppSettingsInput } from '@maka/core/settings';
+import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings';
import type { OnboardingMilestone, OnboardingMilestoneId } from '@maka/core/onboarding';
import { createDefaultSettings, mergeSettings, normalizeSettings } from '@maka/core/settings';
import { sanitizeOnboardingMilestones } from '@maka/core/onboarding';
@@ -45,7 +45,6 @@ export interface SettingsStore {
predicate: (current: AppSettings) => boolean,
patch: ConditionalSettingsPatch,
): Promise<{ applied: boolean; settings: AppSettings }>;
- testNetworkProxy(): Promise<SettingsTestResult>;
/**
* PR110b: upsert a single onboarding milestone. Caller passes the
* desired terminal status; the store stamps `Date.now()` so the
@@ -76,7 +75,7 @@ class FileSettingsStore implements SettingsStore {
private readonly settingsPath: string;
private queue: Promise<void> = Promise.resolve();
- constructor(private readonly workspaceRoot: string) {
+ constructor(workspaceRoot: string) {
this.settingsPath = join(workspaceRoot, 'settings.json');
}
@@ -92,7 +91,12 @@ class FileSettingsStore implements SettingsStore {
private async readOrCreate(): Promise<AppSettings> {
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();
@@ -184,28 +188,6 @@ class FileSettingsStore implements SettingsStore {
return result;
}
- async testNetworkProxy(): Promise<SettingsTestResult> {
- 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() || !proxy.password)) {
- 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 },
- };
- }
-
private async write(settings: AppSettings): Promise<void> {
await mkdir(dirname(this.settingsPath), { recursive: true });
const tempPath = `${this.settingsPath}.${process.pid}.${Date.now()}.tmp`;
@@ -219,3 +201,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<string, unknown> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/scripts/qualify-released-cli-state-root.test.mjs b/scripts/qualify-released-cli-state-root.test.mjs
index 8660793431..8f99dc5e3c 100644
--- a/scripts/qualify-released-cli-state-root.test.mjs
+++ b/scripts/qualify-released-cli-state-root.test.mjs
@@ -197,9 +197,10 @@ 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 === join('/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',
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org