From ab60d29c5d0ae22da24aa53076209481763387df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 23 Oct 2026 18:18:52 +0800 Subject: [PATCH 1/3] fix: persist and enforce onboarding scan permission --- App/backend/src/index.ts | 8 +- .../memmy-config/agent-access.ts | 46 +++++- .../src/services/app-config-service.ts | 20 ++- ...arding-scan-permission.integration.test.ts | 156 ++++++++++++++++++ App/backend/src/tests/index.test.ts | 40 +++++ App/frontend/desktop/src/i18n/messages.ts | 2 + .../desktop/src/pages/memory-sources-page.tsx | 13 +- .../desktop/src/pages/onboarding-page.tsx | 67 ++++---- .../tests/onboarding-page-source.test.ts | 16 -- ...onboarding-permission.interaction.test.tsx | 54 ++++++ .../src/pages/tests/prototype-modals.test.ts | 2 +- Memory/src/agent-source/runtime.ts | 19 +++ Memory/src/config/index.ts | 6 + Memory/src/server/http.ts | 1 + .../contract/memory-rest-service.test.ts | 6 +- 15 files changed, 395 insertions(+), 61 deletions(-) create mode 100644 App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts create mode 100644 App/frontend/desktop/src/pages/tests/onboarding-permission.interaction.test.tsx diff --git a/App/backend/src/index.ts b/App/backend/src/index.ts index ced17a4f7..796399e22 100644 --- a/App/backend/src/index.ts +++ b/App/backend/src/index.ts @@ -101,9 +101,15 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr }); await ensureMemoryScanPreferences( memmyConfigPath, - appStateStore.repositories.bootstrap.getScanPreferences() + appStateStore.repositories.bootstrap.getScanPreferences(), + appStateStore.repositories.bootstrap.getOnboardingState().scanPermission ); const scanPreferencesStore = createMemoryScanPreferencesStore(memmyConfigPath); + // Recover a permission save interrupted between config publication and DB update. + const scanPermission = scanPreferencesStore.getScanPermission(); + if (scanPermission !== undefined && scanPermission !== appStateStore.repositories.bootstrap.getOnboardingState().scanPermission) { + appStateStore.repositories.bootstrap.updateOnboarding({ scanPermission }); + } const permissionManager = createPermissionManager({ appStateStore, diff --git a/App/backend/src/infrastructure/memmy-config/agent-access.ts b/App/backend/src/infrastructure/memmy-config/agent-access.ts index d2fd9abc5..65bf758ca 100644 --- a/App/backend/src/infrastructure/memmy-config/agent-access.ts +++ b/App/backend/src/infrastructure/memmy-config/agent-access.ts @@ -1,11 +1,13 @@ import { readFileSync } from "node:fs"; -import type { PatchScanPreferencesInput, ScanPreferences } from "@memmy/local-api-contracts"; +import type { PatchScanPreferencesInput, ScanPreferences, ScanPermission } from "@memmy/local-api-contracts"; import { ScanPreferencesSchema } from "@memmy/local-api-contracts"; import { mutateRuntimeConfig } from "@memmy/migrations"; import YAML from "yaml"; export interface ScanPreferencesStore { getScanPreferences(): ScanPreferences; + getScanPermission(): ScanPermission | undefined; + setScanPermission(permission: ScanPermission, enableAutomation?: boolean): Promise; updateScanPreferences(patch: PatchScanPreferencesInput): Promise; } @@ -17,16 +19,19 @@ export const DEFAULT_MEMORY_SCAN_PREFERENCES: ScanPreferences = { export async function ensureMemoryScanPreferences( configPath: string, - legacyPreferences: ScanPreferences + legacyPreferences: ScanPreferences, + scanPermission?: ScanPermission ): Promise { await mutateRuntimeConfig(configPath, (root) => { const memory = record(root.memmyMemory); - if (isCompletePreferences(memory.agentAccess)) return; + const access = record(memory.agentAccess); + if (isCompletePreferences(access) && (scanPermission === undefined || access.scanPermission !== undefined)) return; root.memmyMemory = { ...memory, agentAccess: { ...legacyPreferences, - ...record(memory.agentAccess) + ...access, + ...(scanPermission !== undefined && access.scanPermission === undefined ? { scanPermission } : {}) } }; }); @@ -34,6 +39,37 @@ export async function ensureMemoryScanPreferences( export function createMemoryScanPreferencesStore(configPath: string): ScanPreferencesStore { return { + async setScanPermission(permission, enableAutomation = false) { + await mutateRuntimeConfig(configPath, (root) => { + const memory = record(root.memmyMemory); + root.memmyMemory = { + ...memory, + agentAccess: { + ...record(memory.agentAccess), + scanPermission: permission, + ...(permission === "none" || permission === "unset" + ? { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false } + : enableAutomation ? { + autoScanKnownAgents: true, + watchFileChanges: true, + autoInjectSkill: permission === "scan_and_write_skill" + } : {}) + } + }; + }); + }, + getScanPermission() { + try { + const root = record(YAML.parse(readFileSync(configPath, "utf8"))); + const permission = record(record(root.memmyMemory).agentAccess).scanPermission; + if (permission === undefined) return undefined; + return permission === "none" || permission === "scan_only" || permission === "scan_and_write_skill" + ? permission : "unset"; + } catch (error) { + if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + }, getScanPreferences() { return readMemoryScanPreferences(configPath); }, @@ -44,7 +80,7 @@ export function createMemoryScanPreferencesStore(configPath: string): ScanPrefer root.memmyMemory = { ...memory, agentAccess: { - ...readPreferencesRecord(memory.agentAccess), + ...record(memory.agentAccess), ...patch } }; diff --git a/App/backend/src/services/app-config-service.ts b/App/backend/src/services/app-config-service.ts index c69a03335..5f30f2f4a 100644 --- a/App/backend/src/services/app-config-service.ts +++ b/App/backend/src/services/app-config-service.ts @@ -96,6 +96,12 @@ type ResolvedModelConfigTestInput = ModelConfigTestInput & { apiKey: string }; /** Creates create app config service. */ export function createAppConfigService(options: CreateAppConfigServiceOptions): AppConfigService { const modelConfigTester = options.modelConfigTester ?? createHttpModelConfigTester(); + let scanSettingsWrite = Promise.resolve(); + function serializeScanSettings(operation: () => Promise): Promise { + const result = scanSettingsWrite.then(operation); + scanSettingsWrite = result.then(() => undefined, () => undefined); + return result; + } return { async updateSettings(input) { @@ -129,13 +135,21 @@ export function createAppConfigService(options: CreateAppConfigServiceOptions): }, async updateScanPreferences(input) { - return options.scanPreferencesStore + return serializeScanSettings(async () => options.scanPreferencesStore ? options.scanPreferencesStore.updateScanPreferences(input) - : options.bootstrapRepository.updateScanPreferences(input); + : options.bootstrapRepository.updateScanPreferences(input)); }, async updateOnboarding(input) { - return options.bootstrapRepository.updateOnboarding(input); + return serializeScanSettings(async () => { + // Publish the authorization and automation switches together before advancing + // the guide. A later failure leaves the durable refusal in place for restart. + if (input.scanPermission !== undefined) { + await options.scanPreferencesStore?.setScanPermission(input.scanPermission, input.currentStep === "scan_permission_required"); + await options.memoryClient?.reloadConfig({ reason: "scan_permission_saved" }); + } + return options.bootstrapRepository.updateOnboarding(input); + }); }, async setImprovementProgram(input) { diff --git a/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts b/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts new file mode 100644 index 000000000..a238c3af9 --- /dev/null +++ b/App/backend/src/services/tests/onboarding-scan-permission.integration.test.ts @@ -0,0 +1,156 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, expect, it, vi } from "vitest"; +import YAML from "yaml"; +import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { createMemoryScanPreferencesStore, ensureMemoryScanPreferences } from "../../infrastructure/memmy-config/agent-access.js"; +import { createAppConfigService } from "../app-config-service.js"; +import { createAgentSourceExecutor } from "../../../../../Memory/src/agent-source/runtime.js"; +import { createSourceRegistry } from "../../../../../Memory/src/agent-source/adapters/source-registry.js"; +import type { MemoryService } from "../../../../../Memory/src/service/memory-service.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.restoreAllMocks(); }); +const deny = { scanPermission: "none", currentStep: "product_tour_required", firstEncounterReportStatus: "skipped" } as const; + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), "memmy-onboarding-permission-")); + const configPath = join(root, "config.yaml"); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + cleanups.push(async () => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const bootstrapRepository = store.repositories.bootstrap; + await ensureMemoryScanPreferences(configPath, bootstrapRepository.getScanPreferences(), "unset"); + const preferences = createMemoryScanPreferencesStore(configPath); + const detect = vi.fn(async () => true); + const scan = vi.fn(); + const addMemory = vi.fn(() => ({ id: "fixture", duplicate: false })); + const makeExecutor = () => { + const executor = createAgentSourceExecutor({ + service: { addMemory, enqueuePendingImportSummaries: vi.fn(), clearAllData: vi.fn(() => ({ deleted: true })) } as unknown as MemoryService, + configPath, statePath: join(root, "sources.json"), initialScanDelayMs: 1, scheduledScanIntervalMs: 10, + sourceRegistry: createSourceRegistry([{ + descriptor: { sourceId: "fixture", displayName: "Fixture", builtin: true, dataPath: root }, detect, + async *scan() { scan(); yield { sourceId: "fixture", messageId: "one", conversationId: "conversation", role: "user", content: "Synthetic onboarding regression", createdAt: "2026-10-23T00:00:00Z", workspacePath: null, gitRoot: null, rawMeta: {} }; yield { sourceId: "fixture", messageId: "two", conversationId: "conversation", role: "assistant", content: "Recorded", createdAt: "2026-10-23T00:01:00Z", workspacePath: null, gitRoot: null, rawMeta: {} }; } + }]) + }); + cleanups.push(async () => { await executor.dispose(); }); + return executor; + }; + const executor = makeExecutor(); + const reloadConfig = vi.fn(async () => { await executor.reconcileScanPermission!(); return {} as never; }); + const service = createAppConfigService({ bootstrapRepository, scanPreferencesStore: preferences, memoryClient: { reloadConfig } }); + const access = () => YAML.parse(readFileSync(configPath, "utf8")).memmyMemory.agentAccess; + return { service, bootstrapRepository, preferences, executor, makeExecutor, reloadConfig, configPath, detect, scan, addMemory, access }; +} + +it("refusal disables automation and survives a new executor even if toggles are later enabled", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + expect(f.access()).toMatchObject({ scanPermission: "none", autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false }); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); + await f.preferences.updateScanPreferences({ autoScanKnownAgents: true, watchFileChanges: true }); + await f.executor.dispose(); + const restarted = f.makeExecutor(); + restarted.startAutomation(); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(f.detect).not.toHaveBeenCalled(); + await expect(restarted.startScan({ sourceId: "all" })).rejects.toThrow("not authorized"); + expect(f.addMemory).not.toHaveBeenCalled(); +}); + +it("failed config persistence does not advance onboarding and can be retried", async () => { + const f = await fixture(); + vi.spyOn(f.preferences, "setScanPermission").mockRejectedValueOnce(new Error("disk full")); + await expect(f.service.updateOnboarding(deny)).rejects.toThrow("disk full"); + expect(f.bootstrapRepository.getOnboardingState().currentStep).toBe("scan_permission_required"); + expect(f.reloadConfig).not.toHaveBeenCalled(); + await f.service.updateOnboarding(deny); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); +}); + +it.each(["reload", "database"])("a %s failure after config publication remains denied after restart and supports retry", async failure => { + const f = await fixture(); + if (failure === "reload") f.reloadConfig.mockRejectedValueOnce(new Error("offline")); + else vi.spyOn(f.bootstrapRepository, "updateOnboarding").mockImplementationOnce(() => { throw new Error("database failure"); }); + await expect(f.service.updateOnboarding(deny)).rejects.toThrow(); + expect(f.bootstrapRepository.getOnboardingState().currentStep).toBe("scan_permission_required"); + await ensureMemoryScanPreferences(f.configPath, f.bootstrapRepository.getScanPreferences(), f.bootstrapRepository.getOnboardingState().scanPermission); + await f.executor.dispose(); + const restarted = f.makeExecutor(); + restarted.startAutomation(); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(f.preferences.getScanPermission()).toBe("none"); + expect(f.access().scanPermission).toBe("none"); + expect(f.scan).not.toHaveBeenCalled(); + await f.service.updateOnboarding(deny); + expect(f.bootstrapRepository.getOnboardingState()).toMatchObject(deny); +}); + +it("refusal during an active scan waits for cancellation and prevents history imports", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + let release!: () => void; + f.detect.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve(true); })); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.detect).toHaveBeenCalled()); + let saved = false; + const saving = f.service.updateOnboarding(deny).then(() => { saved = true; }); + try { + await vi.waitFor(() => expect(f.access().scanPermission).toBe("none")); + expect(saved).toBe(false); + release(); + await saving; + expect(f.executor.scanStatus().running).toBe(false); + expect(f.addMemory).not.toHaveBeenCalled(); + await expect(f.executor.startScan({ sourceId: "all" })).rejects.toThrow("not authorized"); + } finally { release(); await saving; } +}); + +it("allowed onboarding still scans and imports a synthetic history", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.executor.scanStatus().running).toBe(false)); + expect(f.executor.scanStatus().error).toBeNull(); + expect(f.addMemory).toHaveBeenCalledOnce(); +}); + + +it("denial does not block clearing local memory", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + await expect(f.executor.clearAllData()).resolves.toEqual({ deleted: true }); +}); + +it("denial also cancels a paused scan and repeated refusal is idempotent", async () => { + const f = await fixture(); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + let release!: () => void; + f.detect.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve(true); })); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.detect).toHaveBeenCalled()); + await f.executor.pauseScan(); + const saving = f.service.updateOnboarding(deny); + try { + await vi.waitFor(() => expect(f.access().scanPermission).toBe("none")); + release(); + await saving; + await f.service.updateOnboarding(deny); + expect(f.executor.scanStatus()).toMatchObject({ running: false, status: "cancelled" }); + expect(f.addMemory).not.toHaveBeenCalled(); + } finally { release(); await saving; } +}); + + +it("explicit manual reauthorization preserves disabled automation; onboarding grant enables it", async () => { + const f = await fixture(); + await f.service.updateOnboarding(deny); + await f.service.updateOnboarding({ scanPermission: "scan_only" }); + expect(f.access()).toMatchObject({ scanPermission: "scan_only", autoScanKnownAgents: false, watchFileChanges: false }); + await f.executor.startScan({ sourceId: "all" }); + await vi.waitFor(() => expect(f.executor.scanStatus().running).toBe(false)); + expect(f.addMemory).toHaveBeenCalledOnce(); + await f.service.updateOnboarding({ scanPermission: "scan_only", currentStep: "scan_permission_required" }); + expect(f.access()).toMatchObject({ scanPermission: "scan_only", autoScanKnownAgents: true, watchFileChanges: true }); +}); diff --git a/App/backend/src/tests/index.test.ts b/App/backend/src/tests/index.test.ts index cb99cf0e6..8db7363cd 100644 --- a/App/backend/src/tests/index.test.ts +++ b/App/backend/src/tests/index.test.ts @@ -76,6 +76,46 @@ describe("local api", () => { } }); + it("recovers a refused permission after a failed save response and backend restart", async () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-backend-refusal-restart-")); + const databasePath = join(tempDir, "app.sqlite"); + const memmyConfigPath = join(tempDir, "config.yaml"); + const initial = createAppStateStore({ databasePath }); + initial.repositories.bootstrap.updateOnboarding({ scanPermission: "scan_only" }); + initial.close(); + const baseClient = createMockMemoryClient(); + const options = { + databasePath, memmyConfigPath, + runtimeConfigPath: join(tempDir, "runtime.json"), + localToken: "test-token", + cloudClient: createMockCloudClient(), + memoryClient: { + ...baseClient, + async reloadConfig(input: Parameters[0]) { + if (input?.reason === "scan_permission_saved") throw new Error("simulated Memory offline"); + return baseClient.reloadConfig(input); + } + } + }; + backend = await createLocalBackend(options); + const response = await fetch(`${backend.runtimeConfig.baseUrl}/api/app/onboarding`, { + method: "PATCH", headers: { "x-memmy-local-token": "test-token", "content-type": "application/json" }, + body: JSON.stringify({ scanPermission: "none", currentStep: "product_tour_required" }) + }); + expect(response.status).toBe(500); + expect(YAML.parse(readFileSync(memmyConfigPath, "utf8")).memmyMemory.agentAccess).toMatchObject({ + scanPermission: "none", autoScanKnownAgents: false, watchFileChanges: false + }); + await backend.close(); + backend = await createLocalBackend({ ...options, memoryClient: baseClient }); + const restored = createAppStateStore({ databasePath }); + try { + expect(restored.repositories.bootstrap.getOnboardingState()).toMatchObject({ + scanPermission: "none", currentStep: "scan_permission_required" + }); + } finally { restored.close(); } + }); + it("reloads Memory config when the desktop backend starts", async () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-backend-startup-reload-")); const baseClient = createMockMemoryClient(); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 87582856c..3ddf490ed 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -452,6 +452,7 @@ export const zhCNMessages = { "onboarding.permission.writeTitle": "让常用 Agent 自动接入 Memmy 记忆", "onboarding.permission.writeBody": "继续使用原来的 Agent,切换工具或新开对话时,自动同步与注入相关背景", "onboarding.permission.notice": "你可以随时在「记忆管理 -> 跨 Agent 接入」中调整授权", + "onboarding.permission.saveFailed": "保存扫描设置失败,请重试。", "onboarding.permission.none": "不允许", "onboarding.permission.scan": "仅允许扫描", "onboarding.permission.all": "全部允许", @@ -2649,6 +2650,7 @@ export const enUSMessages: Record = { "onboarding.permission.writeTitle": "Let your usual Agents connect to Memmy memory", "onboarding.permission.writeBody": "Keep using your Agents; when you switch tools or start a new chat, relevant context is synced and injected automatically", "onboarding.permission.notice": "You can change this later in Memory -> Cross-Agent access", + "onboarding.permission.saveFailed": "Could not save scanning settings. Please try again.", "onboarding.permission.none": "Deny", "onboarding.permission.scan": "Scan only", "onboarding.permission.all": "Allow all", diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index 3256c9227..9f8187605 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -507,8 +507,17 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { return; } - void clients.config - .updateScanPreferences(preferences) + void (async () => { + // Enabling automation is an explicit grant; a one-off manual scan does + // not itself enable these switches. + if (preferences.autoInjectSkill === true) { + const onboarding = await clients.config.updateOnboarding({ scanPermission: "scan_and_write_skill" }); + dispatch(appActions.onboardingUpdated(onboarding)); + } else if (preferences.autoScanKnownAgents === true || preferences.watchFileChanges === true) { + await ensureScanPermission(); + } + return clients.config.updateScanPreferences(preferences); + })() .then((persisted) => dispatch(appActions.scanPreferencesUpdated(persisted))) .catch((error) => { dispatch(appActions.scanPreferencesUpdated(previous)); diff --git a/App/frontend/desktop/src/pages/onboarding-page.tsx b/App/frontend/desktop/src/pages/onboarding-page.tsx index 81178d537..9a4aba49b 100644 --- a/App/frontend/desktop/src/pages/onboarding-page.tsx +++ b/App/frontend/desktop/src/pages/onboarding-page.tsx @@ -66,6 +66,9 @@ export function OnboardingPage() { const { clients } = useApiClients(); const { track } = useAnalytics(); const { t, language } = useTranslation(); + const permissionSavingRef = useRef(false); + const [permissionSaving, setPermissionSaving] = useState(false); + const [permissionError, setPermissionError] = useState(null); const [firstScanStep, setFirstScanStep] = useState(null); const [firstScanAgents, setFirstScanAgents] = useState(null); const [firstReportPayload, setFirstReportPayload] = useState(null); @@ -237,8 +240,22 @@ export function OnboardingPage() { } as const : { completed: false, currentStep: "scan_permission_required", scanPermission: permission } as const; - dispatch(appActions.onboardingUpdated(patch)); - dispatch(appActions.scanPreferencesUpdated(preferences)); + if (permissionSavingRef.current || !clients) return; + permissionSavingRef.current = true; + setPermissionSaving(true); + setPermissionError(null); + try { + const persistedPatch = await clients.config.updateOnboarding(patch); + dispatch(appActions.scanPreferencesUpdated(preferences)); + dispatch(appActions.onboardingUpdated(persistedPatch)); + } catch (error) { + console.warn("save scan permission failed", error); + setPermissionError(t("onboarding.permission.saveFailed")); + return; + } finally { + permissionSavingRef.current = false; + setPermissionSaving(false); + } track(buildOnboardingStepCompletedEvent({ step: "scan_permission", choice: permission, @@ -248,9 +265,6 @@ export function OnboardingPage() { prepareFirstScanUi(permission === "scan_and_write_skill" ? "checking_plugins" : "scanning"); if (clients) { try { - const persistedPatch = await clients.config.updateOnboarding(patch); - dispatch(appActions.onboardingUpdated(persistedPatch)); - dispatch(appActions.scanPreferencesUpdated(await clients.config.updateScanPreferences(preferences))); if (permission === "scan_and_write_skill") { void startFirstScanInBackground().catch((error) => { console.warn("start first agent source scan failed", error); @@ -268,19 +282,6 @@ export function OnboardingPage() { console.warn("start first agent source scan failed", error); } } - } else { - void clients?.config - .updateOnboarding(patch) - .then((persistedPatch) => { - dispatch(appActions.onboardingUpdated(persistedPatch)); - return clients.config.updateScanPreferences(preferences); - }) - .then((persistedPreferences) => { - dispatch(appActions.scanPreferencesUpdated(persistedPreferences)); - }) - .catch((error) => { - console.warn("save scan permission failed", error); - }); } } @@ -350,9 +351,19 @@ export function OnboardingPage() { await startFirstScanWithAnimation(); } - function returnToScanPermission() { + async function returnToScanPermission() { const onboardingPatch = { completed: false, currentStep: "scan_permission_required", scanPermission: "unset" } as const; - const preferences = { autoScanKnownAgents: true, watchFileChanges: true, autoInjectSkill: false }; + const preferences = { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false }; + if (!clients || permissionSavingRef.current) return; + permissionSavingRef.current = true; + try { + await clients.config.updateOnboarding(onboardingPatch); + } catch (error) { + dispatch(appActions.agentSourcesFailed(error instanceof Error ? error.message : String(error))); + return; + } finally { + permissionSavingRef.current = false; + } setPluginConflictOpen(false); setPluginConflicts([]); @@ -369,18 +380,6 @@ export function OnboardingPage() { setFirstScanStep(null); dispatch(appActions.onboardingUpdated(onboardingPatch)); dispatch(appActions.scanPreferencesUpdated(preferences)); - void clients?.config - .updateOnboarding(onboardingPatch) - .then((persistedPatch) => dispatch(appActions.onboardingUpdated(persistedPatch))) - .catch((error) => { - console.warn("return to scan permission failed", error); - }); - void clients?.config - .updateScanPreferences(preferences) - .then((persistedPreferences) => dispatch(appActions.scanPreferencesUpdated(persistedPreferences))) - .catch((error) => { - console.warn("reset scan preferences failed", error); - }); } async function finishMemoryPluginConflictInstall(replace: boolean, conflicts: AgentSourceMemoryPluginConflict[]) { @@ -843,9 +842,11 @@ export function OnboardingPage() {

{t("onboarding.permission.notice")}

+ {permissionError &&

{permissionError}

}