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((resolve) => { + acceptAuthorization = resolve; + }); + const proxy = createServer((request, response) => { + acceptAuthorization(request.headers["proxy-authorization"]); + response.writeHead(200, { "content-length": "0", connection: "close" }); + response.end(); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", () => resolve()); + }); + const address = proxy.address(); + if (!address || typeof address === "string") { + throw new Error("Local proxy did not expose a TCP port"); + } + + try { + await 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((resolve) => proxy.close(() => resolve())); + } +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index fc1b4e558b..74c508e99f 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3066,6 +3066,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, @@ -3084,7 +3085,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 { 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 = {}): { 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( + '
', + ); + 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 { + const outdir = await mkdtemp( + resolve(REPO_ROOT, "apps/desktop/dist/main/__tests__/password-input-"), + ); + const outfile = resolve(outdir, "password-input.mjs"); + await mkdir(dirname(outfile), { recursive: true }); + await build({ + entryPoints: [ + resolve( + REPO_ROOT, + "apps/desktop/src/renderer/settings/password-input.tsx", + ), + ], + outfile, + bundle: true, + packages: "external", + platform: "node", + format: "esm", + jsx: "automatic", + target: "node20", + logLevel: "silent", + }); + return (await import( + `${pathToFileURL(outfile).href}?t=${Date.now()}` + )) as typeof PasswordInputModule; +} + +function reactProps(element: Element): Record { + const key = Object.keys(element).find((candidate) => + candidate.startsWith("__reactProps$"), + ); + return key + ? ((element as unknown as Record)[key] as Record) + : {}; +} diff --git a/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts b/apps/desktop/src/main/__tests__/proxy-password-draft.test.ts new file mode 100644 index 0000000000..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() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +test("proxy password typing remains local until the complete draft is committed", async () => { + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + }); + + for (const value of ["s", "se", "sec", "secret"]) draft.edit(value); + assert.deepEqual(saved, []); + assert.equal(draft.value, "secret"); + + await draft.commit(); + assert.deepEqual(saved, ["secret"]); + assert.equal(draft.value, ""); +}); + +test("Enter followed by focus exit reuses one in-flight save", async () => { + const write = deferred(); + let calls = 0; + const draft = createProxyPasswordDraft(async () => { + calls += 1; + await write.promise; + }); + draft.edit("complete-secret"); + + const entered = draft.commit(); + const blurred = draft.commit(); + + assert.equal(entered, blurred); + assert.equal(calls, 1); + write.resolve(); + await entered; + assert.equal(draft.pending, false); +}); + +test("a failed save retains the complete draft for retry", async () => { + const draft = createProxyPasswordDraft(async () => { + throw new Error("save failed"); + }); + draft.edit("complete-secret"); + + await assert.rejects(draft.commit(), /save failed/); + + assert.equal(draft.value, "complete-secret"); + assert.equal(draft.pending, false); +}); + +test("an old save response never clears edits made while it was pending", async () => { + const first = deferred(); + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + if (saved.length === 1) await first.promise; + }); + draft.edit("first-secret"); + const savingFirst = draft.commit(); + draft.edit("second-secret"); + + first.resolve(); + await savingFirst; + assert.equal(draft.value, "second-secret"); + + await draft.commit(); + assert.deepEqual(saved, ["first-secret", "second-secret"]); + assert.equal(draft.value, ""); +}); + +test("a test-time commit waits for an in-flight save then commits newer edits", async () => { + const first = deferred(); + const saved: string[] = []; + const draft = createProxyPasswordDraft(async (secret) => { + saved.push(secret); + if (saved.length === 1) await first.promise; + }); + draft.edit("first-secret"); + void draft.commit(); + draft.edit("latest-secret"); + + const beforeTest = draft.commit(); + first.resolve(); + await beforeTest; + + assert.deepEqual(saved, ["first-secret", "latest-secret"]); + assert.equal(draft.value, ""); +}); + +test("cancel clears only work that has not entered the save lane", async () => { + const write = deferred(); + const draft = createProxyPasswordDraft(async () => write.promise); + draft.edit("queued-secret"); + const saving = draft.commit(); + draft.edit("unsubmitted-change"); + + draft.cancel(); + assert.equal(draft.value, "queued-secret"); + + write.resolve(); + await saving; + assert.equal(draft.value, ""); + + draft.edit("local-only"); + draft.cancel(); + assert.equal(draft.value, ""); +}); + +test("empty drafts are keep operations", async () => { + let calls = 0; + const draft = createProxyPasswordDraft(async () => { + calls += 1; + }); + + await draft.commit(); + + assert.equal(calls, 0); +}); + +test("proxy testing waits for the save and aborts when that save fails", async () => { + const write = deferred(); + let tests = 0; + const draft = createProxyPasswordDraft(async () => write.promise); + draft.edit("complete-secret"); + + const testing = runAfterProxyPasswordCommit(draft, async () => { + tests += 1; + return "tested"; + }); + assert.equal(tests, 0); + write.reject(new Error("save failed")); + await assert.rejects(testing, /save failed/); + assert.equal(tests, 0); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts index 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; 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).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; + 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; + 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).network.proxy.credentialTarget = { + protocol: 'https', + host: 'Source.Proxy.Example', + port: 8443, + username: 'source-user', + }; + const replace = adaptRuntimeHostConfigImport(replaceBundle); + assert.deepEqual( + (replace.data.settings as Record).network.proxy, + { + host: '10.0.0.2', + credential: { + kind: 'replace', + secret: 'complete-secret', + 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).network.proxy.credential, + { kind: 'delete' }, + ); + + const keep = adaptRuntimeHostConfigImport( + importBundle(['settings', 'credentials'], undefined), + ); + assert.equal( + 'credential' in (keep.data.settings as Record).network.proxy, + false, + ); + + const ignored = adaptRuntimeHostConfigImport( + importBundle(['settings'], 'handcrafted-secret'), + ); + assert.deepEqual( + (ignored.data.settings as Record).network.proxy, + { host: '10.0.0.2' }, + ); +}); + +test('Runtime Host config import rejects 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).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 = { + 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; + 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["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((resolve) => { + release = resolve; + }); + const fixture = createModuleFixture({ beforeSetCredential: () => blocked }); + + const replace = fixture.module.update({ + network: { + proxy: { + authEnabled: true, + credential: { kind: "replace", secret: "complete-secret" }, + }, + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + const disable = fixture.module.update({ + network: { proxy: { authEnabled: false } }, + }); + + assert.deepEqual(fixture.events, ["set:complete-secret"]); + release(); + await Promise.all([replace, disable]); + assert.deepEqual(fixture.events, ["set:complete-secret", "delete"]); + assert.equal(fixture.secret(), undefined); +}); + +test("proxy tests wait for the lane and a failed operation does not poison it", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const fixture = createModuleFixture({ + beforeSetCredential: () => blocked, + failFirstSet: true, + }); + const replace = fixture.module.update({ + network: { + proxy: { + 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((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 = new Set([ @@ -52,9 +57,11 @@ const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ export interface ConfigTransferDeps { connectionStore: { list(): Promise; save(c: LlmConnection): Promise }; - settingsStore: { update(patch: UpdateAppSettingsInput): Promise }; + settingsStore: { + update(patch: UpdateAppSettingsInput): Promise<{ skippedCredentials: number }>; + }; credentialStore: { - setSecret(slug: string, kind: CredentialKind, value: string): Promise; + setSecret(entry: ExportedCredential): Promise; }; writeMemory(content: string): Promise; } @@ -72,12 +79,13 @@ export async function applyConfigImport( deps: ConfigTransferDeps, ): Promise { const result: ConfigImportResult = {}; - // Credentials are only applied for connections actually written this import - // (created or overwritten). A slug the user chose to skip must not have its - // stored secret silently overwritten. - const appliedConnectionSlugs = new Set(); + // A connection snapshot limits credential writes to connections created or + // overwritten by this import. Credentials-only bundles instead require an + // existing slug whose provider and effective endpoint match the export. + const credentialTargets = new Map(); + const hasConnectionSnapshot = Array.isArray(bundle.data.connections); - if (Array.isArray(bundle.data.connections)) { + if (hasConnectionSnapshot) { const incoming = bundle.data.connections as LlmConnection[]; const existing = await deps.connectionStore.list(); const plan = planConnectionMerge(existing, incoming, strategy); @@ -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, +): boolean { + return ( + binding !== undefined && + binding.providerType === target.providerType && + canonicalEndpoint(binding.effectiveBaseUrl) === canonicalConnectionEffectiveBaseUrl(target) + ); +} + +function credentialConnectionBinding( + connection: LlmConnection, +): NonNullable { + return { + providerType: connection.providerType, + effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection), + }; +} + +function canonicalEndpoint(value: string): string | null { + try { + return new URL(value).toString(); + } catch { + return null; + } +} diff --git a/apps/desktop/src/main/runtime-host-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[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> { + return this.request("runtime.policy.network-proxy.update", input); + } + queryMemory(input: MemoryQueryInput): Promise { return this.request("memory.query", input); } diff --git a/apps/desktop/src/main/runtime-host-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; readonly appVersion: string; + readonly settingsModule: RuntimeHostSettingsModule; + readonly emitConnectionsChanged: () => void; +} + +interface RuntimeHostConfigGatherDeps { + readonly client: DesktopRuntimeHostClient; + readonly appVersion: string; readonly getSettings: () => Promise; - readonly updateSettings: ( +} + +interface RuntimeHostConfigTransferDeps { + readonly client: DesktopRuntimeHostClient; + readonly updateSettingsForConfigImport: ( patch: UpdateAppSettingsInput, - ) => Promise; - 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> = { + 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 { + entry: ExportedCredential, +): Promise { const catalog = await client.loadConnectionCatalog(); - const connection = catalog.connections.find((item) => item.slug === slug); - if (!connection) throw new Error(`Imported Connection not found: ${slug}`); + const connection = catalog.connections.find((item) => item.slug === entry.slug); + if (!connection || !matchesCredentialConnection(entry.connection, connection)) return false; const locator = - kind === 'request_headers' + entry.kind === 'request_headers' ? ({ scope: 'connection', connectionId: connection.connectionId, kind: 'request_headers', } as const) : connectionCredentialLocator(connection); - if (!locator || locator.kind !== kind) return; + if (!locator || locator.kind !== entry.kind) return false; const current = await client.queryCredential(locator); const saved = await client.setCredential({ locator, expected: current?.configured ? { credentialId: current.credentialId, revision: current.revision } : null, - secret: value, + expectedConnection: connectionCredentialTarget(connection), + secret: entry.value, }); - if (saved.kind !== 'committed') { - throw new Error(`Unable to save imported Connection credential: ${saved.kind}`); - } + return saved.kind === 'committed'; } function exportLocators( connections: readonly ConnectionCatalogEntry[], -): CredentialLocator[] { +): CredentialExportRequest[] { return [ ...connections.flatMap((connection) => { const locator = connectionCredentialLocator(connection); @@ -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 { + return { + providerType: connection.providerType, + effectiveBaseUrl: canonicalConnectionEffectiveBaseUrl(connection), + }; +} + function restoreHostSettingsSecrets( settings: AppSettings, secrets: ReadonlyMap, -): AppSettings { +): Record { const proxy = secrets.get(locatorKey({ scope: 'network_proxy', kind: 'password' })) ?? ''; const webSearch = secrets.get( locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), ) ?? ''; + const { + passwordConfigured: _passwordConfigured, + ...proxySettings + } = settings.network.proxy as typeof settings.network.proxy & { + passwordConfigured?: boolean; + }; return { ...settings, network: { - proxy: { ...settings.network.proxy, password: proxy }, + proxy: { ...proxySettings, password: proxy }, }, webSearch: { ...settings.webSearch, @@ -362,6 +508,130 @@ function restoreHostSettingsSecrets( }; } +function projectHostSettingsSecrets( + proxyTarget: NetworkProxyCredentialTarget | undefined, + secrets: ReadonlyMap, +): Record | undefined { + const proxy = secrets.get( + locatorKey({ scope: 'network_proxy', kind: 'password' }), + ); + const tavily = secrets.get( + locatorKey({ scope: 'web_search', provider: 'tavily', kind: 'api_key' }), + ); + if (proxy === undefined && tavily === undefined) return undefined; + + return { + ...(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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function connectionCredentialLocator( connection: ConnectionCatalogEntry, ): Extract | null { diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index f7b6065941..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; } +export type RuntimeHostSettingsModuleDeps = Omit< + RuntimeHostSettingsIpcDeps, + "ipcMain" +>; + +export interface RuntimeHostSettingsModule { + get(): Promise; + update(patch: UpdateAppSettingsInput): Promise; + testNetworkProxy(input?: TestProxyInput): Promise; +} + +export interface RuntimeHostSettingsExclusiveAccess { + get(): Promise; + update(patch: UpdateAppSettingsInput): Promise; + updateForConfigImport( + patch: UpdateAppSettingsInput, + ): Promise; +} + +export interface RuntimeHostSettingsImportResult { + readonly settings: RuntimeHostAppSettings; + readonly skippedCredentials: number; +} + +type RuntimeHostSettingsExclusiveRunner = ( + operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise, +) => Promise; + +const exclusiveRunners = new WeakMap< + RuntimeHostSettingsModule, + RuntimeHostSettingsExclusiveRunner +>(); + +interface RuntimeHostSettingsIpcRegistrationDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly module: RuntimeHostSettingsModule; +} + +export function createRuntimeHostSettingsModule( + deps: RuntimeHostSettingsModuleDeps, +): RuntimeHostSettingsModule { + let lane: Promise = Promise.resolve(); + + function enqueue(operation: () => Promise): Promise { + const result = lane.then(operation, operation); + lane = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + const module: RuntimeHostSettingsModule = { + get: () => enqueue(() => loadRuntimeHostSettingsWithoutLane(deps)), + update: (patch) => + enqueue(() => + 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( + module: RuntimeHostSettingsModule, + operation: (access: RuntimeHostSettingsExclusiveAccess) => Promise, +): Promise { + const run = exclusiveRunners.get(module); + if (!run) { + throw new Error('Runtime Host Settings module does not own an exclusive lane'); + } + return run(operation); +} + export function registerRuntimeHostSettingsIpc( - deps: RuntimeHostSettingsIpcDeps, + deps: RuntimeHostSettingsIpcRegistrationDeps, ): void { + const module = 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 => { - const settings = await updateRuntimeHostSettings(deps, patch); + ): Promise> => { + const settings = await module.update(patch); return buildSettingsUpdateResult(settings, patch); }, ); } -export async function updateRuntimeHostSettings( - deps: RuntimeHostSettingsIpcDeps, - patch: UpdateAppSettingsInput, -): Promise { - await applyHostPatch(deps.client, patch); - const clientPatch = clientOwnedSettingsPatch(patch); - const local = hasSettingsPatch(clientPatch) - ? await deps.settingsStore.update(clientPatch) - : await deps.settingsStore.get(); - await deps.applyClientSettings(local); - return loadRuntimeHostSettings(deps); -} - function toRuntimeHostProxyPolicy( proxy: TestProxySettings, autoBypassDomains: readonly string[], ): 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 { + 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 { +async function loadRuntimeHostSettingsWithoutLane( + deps: RuntimeHostSettingsModuleDeps, +): Promise { 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 { + 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 { +): Promise { + 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["proxy"]>, +): Promise { + const [policy, credential] = await Promise.all([ + client.queryRuntimePolicy(), + client.queryCredential(PROXY_CREDENTIAL), + ]); + const networkProxy = { + ...policy.policy.networkProxy, + ...withoutCredential(patch), + }; + const operation = + patch.credential?.kind === "replace" + ? patch.credential + : !networkProxy.authEnabled || patch.credential?.kind === "delete" + ? ({ kind: "delete" } as const) + : ({ kind: "keep" } as const); + const result = await client.updateNetworkProxy({ + expectedPolicyRevision: policy.revision, + expectedCredential: credential?.configured + ? { + locator: credential.locator, + credentialId: credential.credentialId, + revision: credential.revision, + } + : null, + networkProxy, + credential: operation, + }); + if (result.kind === "committed") return 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["proxy"]>, ): Partial { - const { password: _password, ...value } = patch; + const { + credential: _credential, + password: _legacyPassword, + passwordConfigured: _derivedStatus, + ...value + } = patch as typeof patch & { + password?: unknown; + passwordConfigured?: unknown; + }; return value; } + +function validateProxyPatch( + proxy: NonNullable["proxy"] | undefined, +): void { + const operation = proxy?.credential; + if (!operation) return; + if (operation.kind === "replace") { + if (typeof operation.secret !== "string" || operation.secret.length === 0) { + throw new Error("Proxy credential replacement requires a non-empty password"); + } + if (proxy.authEnabled === false) { + throw new Error( + "Cannot replace the proxy credential while authentication is disabled", + ); + } + return; + } + if (operation.kind !== "delete") { + throw new Error("Unsupported proxy credential operation"); + } +} diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index 2404d1463f..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 { const proxy = { ...settings.network.proxy } as Record; delete proxy.password; + delete proxy.passwordConfigured; const channels: Record = {}; for (const [provider, channel] of Object.entries(settings.botChat.channels)) { @@ -209,6 +164,14 @@ export function stripSettingsSecretsForExport( }; } +export function buildSettingsUpdateResult( + settings: RuntimeHostAppSettings, + patch: UpdateAppSettingsInput, +): UpdateAppSettingsResult; +export function buildSettingsUpdateResult( + settings: AppSettings, + patch: UpdateAppSettingsInput, +): UpdateAppSettingsResult; export function buildSettingsUpdateResult( settings: AppSettings, patch: UpdateAppSettingsInput, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index da3dc9b7fc..774dfcc6fc 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, @@ -1420,9 +1421,9 @@ export interface MakaBridge { }; settings: { getClient(): Promise; - get(host?: DesktopRuntimeHostRef): Promise; + get(host?: DesktopRuntimeHostRef): Promise; updateClient(patch: UpdateAppSettingsInput): Promise; - update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise; + update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise>; subscribeClientChanged(handler: () => void): () => void; subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c462b5215b..67f127a2fc 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, @@ -3040,13 +3041,13 @@ const makaBridge = { getClient(): Promise { return ipcRenderer.invoke('settings:client:get'); }, - get(host?: DesktopRuntimeHostRef): Promise { + get(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'settings:get'); }, updateClient(patch: UpdateAppSettingsInput): Promise { return ipcRenderer.invoke('settings:client:update', patch); }, - update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise { + update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise> { return invokeSelectedRuntimeHost(host, 'settings:update', patch); }, subscribeClientChanged(handler: () => void): () => void { diff --git a/apps/desktop/src/renderer/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, +): ProxyPasswordDraft { + const saveRef = useRef(save); + saveRef.current = save; + const draftRef = useRef(null); + draftRef.current ??= createProxyPasswordDraft((secret) => + saveRef.current(secret), + ); + const draft = draftRef.current; + const [, rerender] = useReducer((value: number) => value + 1, 0); + useEffect(() => draft.subscribe(rerender), [draft]); + return draft; +} diff --git a/apps/desktop/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; + 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; + cancel(): void; + subscribe(listener: () => void): () => void; +} + +export async function runAfterProxyPasswordCommit( + draft: Pick, + operation: () => Promise, +): Promise { + await draft.commit(); + return operation(); +} + +interface ActiveSave { + readonly secret: string; + readonly promise: Promise; +} + +/** Owns the write-only password draft and hides save de-duplication from UI callers. */ +export function createProxyPasswordDraft( + save: (secret: string) => Promise, +): ProxyPasswordDraft { + let value = ""; + let active: ActiveSave | undefined; + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) listener(); + } + + function commit(): Promise { + if (active) { + if (!value || value === active.secret) return active.promise; + return active.promise.then(() => commit()); + } + if (!value) return Promise.resolve(); + + const secret = value; + let write: Promise; + try { + write = save(secret); + } catch (error) { + write = Promise.reject(error); + } + const promise = write + .then(() => { + if (value === secret) value = ""; + }) + .finally(() => { + if (active?.promise === promise) active = undefined; + notify(); + }); + active = { secret, promise }; + notify(); + return promise; + } + + return { + get value() { + return value; + }, + get pending() { + return active !== undefined; + }, + edit(next) { + if (next === value) return; + value = next; + notify(); + }, + commit, + cancel() { + const next = active?.secret ?? ""; + if (next === value) return; + value = next; + notify(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/desktop/src/renderer/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 运行。请检查 Host 是否为 Windows、路径是否存在,并确认文件名为 bash.exe。', proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', + passwordSavedPlaceholder: '密码已保存;输入新密码以替换', }, about: { loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyDiagnostics: '复制诊断信息', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', @@ -388,6 +390,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { general: { incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'WorkHub is not available yet. This toggle is for development testing and does not enable a usable feature.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', + passwordSavedPlaceholder: 'Password saved; enter a new password to replace it', }, about: { loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and security', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay in the local workspace.', 'Model keys stay in a local credential file; subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task.', 'Messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host logs. The report is written only to the clipboard and is never uploaded automatically.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', diff --git a/apps/desktop/src/renderer/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( + } = useOptimisticSettingsDraft( persistedProxy, (patch) => props .onUpdate({ network: { proxy: patch } }) - .then((result) => result.settings.network.proxy), + .then( + (result) => + result.settings.network.proxy as RuntimeHostNetworkProxySettings, + ), { - onError: (error) => - toast.error( - copy.saveNetworkFailed, - settingsActionErrorMessage(error, locale), - undefined, - { profileId: host.profileId }, - ), + onError: reportNetworkSaveError, }, ); @@ -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 ( - <> + + {(passwordDraft) => ( + <> 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} /> 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} /> )} - + + )} + ); } @@ -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): 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?.(); + }} > 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. */} - {props.value && !props.isDisabled && ( + {(props.hasCopyAction ?? true) && props.value && !props.isDisabled && ( (); + const runtimeHostSettings = new Map(); const runtimeHostConnections = new Map(); return { diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 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 + SettingsResourceState >(() => 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[0]): Promise => { - storyRuntimeHostSettings = mergeSettings(storyRuntimeHostSettings, patch); + const merged = mergeSettings(storyRuntimeHostSettings, patch); + storyRuntimeHostSettings = storyRuntimeSettings( + merged, + patch.network?.proxy?.credential?.kind === 'replace' + ? true + : patch.network?.proxy?.credential?.kind === 'delete' || + patch.network?.proxy?.authEnabled === false + ? false + : storyRuntimeHostSettings.network.proxy.passwordConfigured, + ); return { settings: storyRuntimeHostSettings }; }, subscribeClientChanged: () => () => undefined, diff --git a/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, +): 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, +): 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 & { + 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 { + 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; + proxy: NetworkProxySettingsPatch; }>; botChat: BotChatSettingsPatch; usage: Partial; @@ -681,8 +723,8 @@ export interface UpdateAppSettingsWarnings { personalization?: PersonalizationSettingsWarning[]; } -export interface UpdateAppSettingsResult { - settings: AppSettings; +export interface UpdateAppSettingsResult { + 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 { 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(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> { 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[0], + ): Promise> { + return this.#storeMutation(async () => { + try { + await this.validateMutation({ + expectedRevision: input.expectedPolicyRevision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + } catch (error) { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + 'Network proxy update failed Host validation', + { cause: error }, + ); + } + const result = await this.#stores.operations.updateNetworkProxy(input); + return result.kind === 'committed' + ? { + kind: 'committed' as const, + revision: result.snapshot.revision, + credentialStatus: result.credentialStatus, + } + : result; + }); + } + async #queryCatalog( input: ConnectionCatalogQueryInput, ): Promise> { @@ -241,7 +269,11 @@ export class HostRuntimePolicyCoordinator { ): Promise> { return this.#storeMutation(async () => { const result = await this.#stores.credentialVault.set(input); - if (result.kind === 'connection_not_found' || result.kind === 'credential_stale') { + if ( + result.kind === 'connection_not_found' || + result.kind === 'connection_stale' || + result.kind === 'credential_stale' + ) { return result; } const status = result.snapshot.entries.find((entry) => @@ -259,6 +291,9 @@ export class HostRuntimePolicyCoordinator { ): Promise> { return this.#storeMutation(async () => { const result = await this.#stores.credentialVault.delete(input); + if (result.kind === 'connection_stale') { + throw invariantFailure('Credential deletion returned an impossible connection conflict'); + } if (result.kind === 'connection_not_found' || result.kind === 'credential_stale') { return result; } diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 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 }; + }; + + 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 { + 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 { + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection: ConnectionCredentialTarget, + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection?: ConnectionCredentialTarget, + ): Promise { return this.inLane(async (root) => { const locator = decodeCredentialInput(() => decodeCredentialLocator(rawLocator)); + const expectedConnection = rawExpectedConnection + ? decodeConnectionInput(() => decodeConnectionCredentialTarget(rawExpectedConnection)) + : undefined; + if (expectedConnection && locator.scope !== 'connection') { + throw codecError( + 'invalid_credential_input', + 'Only connection credentials accept a connection target basis', + ); + } if (locator.scope === 'connection') { const catalog = await this.catalog.read(root); - if (!this.validateConnectionCredentialLocator(catalog, locator)) return null; + const connection = findConnection(catalog, locator); + if ( + expectedConnection && + (!connection || + !isDeepStrictEqual(connectionCredentialTarget(connection), expectedConnection)) + ) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected: { + connectionId: expectedConnection.connectionId, + revision: expectedConnection.revision, + }, + actual: connection ? connectionBasis(connection) : null, + }); + } + if (!this.validateConnectionCredentialLocator(catalog, locator)) { + return expectedConnection + ? deepFreeze({ kind: 'exported' as const, material: null }) + : null; + } } const credential = findCredential(await this.vault.read(root), locator); - return credential ? credentialMaterial(credential) : null; + const material = credential + ? { + ...credentialMaterial(credential), + ...(locator.scope === 'network_proxy' + ? { + proxyTarget: networkProxyCredentialTarget( + (await this.policy.read(root)).policy.networkProxy, + ), + } + : {}), + } + : null; + return expectedConnection ? deepFreeze({ kind: 'exported' as const, material }) : material; }); } @@ -1768,6 +1900,95 @@ export class RuntimePolicyCoordinator { } } + private async applyNetworkProxyUpdate( + root: string, + input: UpdateNetworkProxyInput, + ): Promise> { + const policy = await this.policy.read(root); + const vault = await this.vault.read(root); + const locator = networkProxyCredentialLocator(); + const existing = findCredential(vault, locator); + const credentialChanged = + 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 => { + 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 => { + 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, + 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; exportCredentialMaterial( locator: CredentialLocator, ): Promise; + exportCredentialMaterial( + locator: CredentialLocator, + expectedConnection: ConnectionCredentialTarget, + ): Promise; getConnectionRequestHeaders(connectionId: string): Promise; 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; /** * 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 = 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 { 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 { - 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 { 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 { + 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',