Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion App/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 41 additions & 5 deletions App/backend/src/infrastructure/memmy-config/agent-access.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
updateScanPreferences(patch: PatchScanPreferencesInput): Promise<ScanPreferences>;
}

Expand All @@ -17,23 +19,57 @@ export const DEFAULT_MEMORY_SCAN_PREFERENCES: ScanPreferences = {

export async function ensureMemoryScanPreferences(
configPath: string,
legacyPreferences: ScanPreferences
legacyPreferences: ScanPreferences,
scanPermission?: ScanPermission
): Promise<void> {
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 } : {})
}
};
});
}

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);
},
Expand All @@ -44,7 +80,7 @@ export function createMemoryScanPreferencesStore(configPath: string): ScanPrefer
root.memmyMemory = {
...memory,
agentAccess: {
...readPreferencesRecord(memory.agentAccess),
...record(memory.agentAccess),
...patch
}
};
Expand Down
20 changes: 17 additions & 3 deletions App/backend/src/services/app-config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(operation: () => Promise<T>): Promise<T> {
const result = scanSettingsWrite.then(operation);
scanSettingsWrite = result.then(() => undefined, () => undefined);
return result;
}

return {
async updateSettings(input) {
Expand Down Expand Up @@ -137,13 +143,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>> = [];
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<boolean>(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<boolean>(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 });
});
40 changes: 40 additions & 0 deletions App/backend/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemoryClient["reloadConfig"]>[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();
Expand Down
Loading
Loading