From 02a08c73f621225fcfbb7824b571deb31a15aafd Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Mon, 10 Aug 2026 21:12:02 -0700 Subject: [PATCH] fix(memory): keep migration host APIs private --- extensions/memory-core/index.test.ts | 4 +- extensions/memory-core/index.ts | 3 + .../migration/doctor-scoped-memory-preview.ts | 54 +++++------------ package.json | 4 ++ scripts/lib/plugin-sdk-entrypoints.json | 1 + ...lugin-sdk-private-local-only-subpaths.json | 1 + src/plugin-sdk/memory-host-core.ts | 5 -- .../memory-migration-runtime.test.ts | 35 +++++++++++ src/plugin-sdk/memory-migration-runtime.ts | 58 +++++++++++++++++++ src/plugin-sdk/routing.ts | 1 - 10 files changed, 119 insertions(+), 47 deletions(-) create mode 100644 src/plugin-sdk/memory-migration-runtime.test.ts create mode 100644 src/plugin-sdk/memory-migration-runtime.ts diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index d6108e8492d4..16029b4732ec 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -8,6 +8,7 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildMemoryFlushPlan } from "./src/flush-plan.js"; import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js"; +import { builtinScopedMemoryConformanceAdapter } from "./src/memory/scoped-memory-policy.js"; import { buildPromptSection } from "./src/prompt-section.js"; const closeMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => {})); @@ -236,10 +237,11 @@ describe("memory-core plugin runtime registration", () => { expect(closeMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" }); }); - it("declares the selected lazy capability as legacy-only without synthesizing runtime authority", () => { + it("registers the tested scoped-policy adapter while keeping legacy reads unenforced", () => { const capability = registerMemoryCoreCapability(); expect(capability.authorization).toEqual(LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES); + expect(capability.authorizationConformance).toBe(builtinScopedMemoryConformanceAdapter); expect("authorization" in (capability.runtime ?? {})).toBe(false); }); diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index c3e332e48e3e..3c0f17e6bc60 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -21,6 +21,7 @@ import { registerShortTermPromotionDreaming } from "./src/dreaming.js"; import { buildMemoryFlushPlan } from "./src/flush-plan.js"; import type { MemoryCoreAcquireLocalService } from "./src/memory/embedding-local-service.js"; import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js"; +import { builtinScopedMemoryConformanceAdapter } from "./src/memory/scoped-memory-policy.js"; import { buildPromptSection } from "./src/prompt-section.js"; import { registerSessionBackfillGatewayMethods } from "./src/session-backfill-gateway.js"; @@ -290,6 +291,8 @@ export default definePluginEntry({ registerSessionBackfillGatewayMethods(api); api.registerMemoryCapability({ authorization: MEMORY_CORE_AUTHORIZATION_CAPABILITIES, + // Phase 1B publishes the tested policy adapter; Phase 1C owns admitting it for reads. + authorizationConformance: builtinScopedMemoryConformanceAdapter, promptBuilder: buildPromptSection, flushPlanResolver: buildMemoryFlushPlan, runtime: memoryRuntime, diff --git a/extensions/memory-core/src/migration/doctor-scoped-memory-preview.ts b/extensions/memory-core/src/migration/doctor-scoped-memory-preview.ts index 9db7ce723de4..fae59d41add4 100644 --- a/extensions/memory-core/src/migration/doctor-scoped-memory-preview.ts +++ b/extensions/memory-core/src/migration/doctor-scoped-memory-preview.ts @@ -3,7 +3,6 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isValidAgentId } from "openclaw/plugin-sdk/routing"; import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations"; type PreviewKind = "curated" | "memory" | "transcript" | "quarantine"; @@ -96,33 +95,10 @@ async function buildPreview(params: { config: OpenClawConfig; stateDir: string; }): Promise { - const { listAgentIds, readAgentRosterProperty, resolveAgentWorkspaceDir } = - await import("openclaw/plugin-sdk/memory-host-core"); - const roster = readAgentRosterProperty(params.config); - const rawAgentIds = - roster?.kind === "entries" && roster.value && typeof roster.value === "object" - ? Object.keys(roster.value) - : roster?.kind === "list" && Array.isArray(roster.value) - ? roster.value.flatMap((entry) => { - const id = - entry && typeof entry === "object" ? (entry as { id?: unknown }).id : undefined; - return typeof id === "string" ? [id] : []; - }) - : []; - if (rawAgentIds.some((agentId) => !isValidAgentId(agentId))) { - return previewLines({ - items: [], - dmScope: 0, - backend: 1, - filesystem: 0, - sandbox: 0, - invalidAgent: 1, - }); - } - let agentIds: readonly string[]; - try { - agentIds = listAgentIds(params.config); - } catch { + const { resolveMemoryMigrationAgentWorkspaces } = + await import("openclaw/plugin-sdk/memory-migration-runtime"); + const agentWorkspaces = resolveMemoryMigrationAgentWorkspaces(params.config); + if (agentWorkspaces.kind === "invalid-agent") { return previewLines({ items: [], dmScope: 0, @@ -135,33 +111,31 @@ async function buildPreview(params: { const items: PreviewItem[] = []; let filesystem = 0; let sandbox = 0; - for (const agentId of agentIds.toSorted()) { - const workspace = resolveAgentWorkspaceDir(params.config, agentId); + for (const agent of agentWorkspaces.agents.toSorted((left, right) => + left.agentId.localeCompare(right.agentId), + )) { const curated = await scanRegularFiles({ - directory: workspace, - agentId, + directory: agent.workspaceDir, + agentId: agent.agentId, kind: "curated", extension: ".md", }); const memory = await scanRegularFiles({ - directory: path.join(workspace, "memory"), - agentId, + directory: path.join(agent.workspaceDir, "memory"), + agentId: agent.agentId, kind: "memory", }); // Transcripts are direct JSONL files. sessions.json is metadata, never memory content. const transcripts = await scanRegularFiles({ - directory: path.join(params.stateDir, "agents", agentId, "sessions"), - agentId, + directory: path.join(params.stateDir, "agents", agent.agentId, "sessions"), + agentId: agent.agentId, kind: "transcript", extension: ".jsonl", }); items.push(...curated.items, ...memory.items, ...transcripts.items); filesystem += curated.filesystemBlockers + memory.filesystemBlockers + transcripts.filesystemBlockers; - const entry = - params.config.agents?.entries?.[agentId] ?? - params.config.agents?.list?.find((candidate) => candidate?.id === agentId); - if (entry?.sandbox?.mode === "all") { + if (agent.sandboxed) { sandbox += 1; } } diff --git a/package.json b/package.json index 62a84513aee8..31fb767ecef3 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "!dist/plugin-sdk/memory-host-events.d.ts", "!dist/plugin-sdk/memory-host-markdown.d.ts", "!dist/plugin-sdk/memory-host-search.d.ts", + "!dist/plugin-sdk/memory-migration-runtime.d.ts", "!dist/plugin-sdk/message-tool-delivery-hints.d.ts", "!dist/plugin-sdk/migration.d.ts", "!dist/plugin-sdk/migration-runtime.d.ts", @@ -848,6 +849,9 @@ "./plugin-sdk/migration": { "default": "./dist/plugin-sdk/migration.js" }, + "./plugin-sdk/memory-migration-runtime": { + "default": "./dist/plugin-sdk/memory-migration-runtime.js" + }, "./plugin-sdk/migration-runtime": { "default": "./dist/plugin-sdk/migration-runtime.js" }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 80a65189cf54..407e903eaef0 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -147,6 +147,7 @@ "time-runtime", "logging-core", "migration", + "memory-migration-runtime", "migration-runtime", "plugin-state-runtime", "plugin-state-test-runtime", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 718c8bd23572..88a48c6c2447 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -80,6 +80,7 @@ "memory-host-events", "memory-host-markdown", "memory-host-search", + "memory-migration-runtime", "message-tool-delivery-hints", "migration", "migration-runtime", diff --git a/src/plugin-sdk/memory-host-core.ts b/src/plugin-sdk/memory-host-core.ts index ceb107392a09..64cfda4064f7 100644 --- a/src/plugin-sdk/memory-host-core.ts +++ b/src/plugin-sdk/memory-host-core.ts @@ -221,11 +221,6 @@ export type { MemoryPromptSectionBuilder, } from "../plugins/memory-state.js"; export { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; -export { - listAgentIds, - readAgentRosterProperty, - resolveAgentWorkspaceDir, -} from "../agents/agent-scope-config.js"; export { resolveSessionAgentId } from "../agents/agent-scope.js"; export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; diff --git a/src/plugin-sdk/memory-migration-runtime.test.ts b/src/plugin-sdk/memory-migration-runtime.test.ts new file mode 100644 index 000000000000..88f6df01c1cb --- /dev/null +++ b/src/plugin-sdk/memory-migration-runtime.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.js"; +import { resolveMemoryMigrationAgentWorkspaces } from "./memory-migration-runtime.js"; + +describe("resolveMemoryMigrationAgentWorkspaces", () => { + it("uses canonical entries precedence and returns their resolved workspace policy", () => { + const result = resolveMemoryMigrationAgentWorkspaces({ + agents: { + entries: { + main: { sandbox: { mode: "all" }, workspace: "/canonical-workspace" }, + }, + list: [{ id: "..", workspace: "/ignored-workspace" }], + }, + } as OpenClawConfig); + + expect(result).toEqual({ + kind: "resolved", + agents: [ + { + agentId: "main", + sandboxed: true, + workspaceDir: "/canonical-workspace", + }, + ], + }); + }); + + it("rejects invalid roster identities before resolving agent-owned paths", () => { + const result = resolveMemoryMigrationAgentWorkspaces({ + agents: { list: [{ id: "../outside", workspace: "/outside" }] }, + } as OpenClawConfig); + + expect(result).toEqual({ kind: "invalid-agent" }); + }); +}); diff --git a/src/plugin-sdk/memory-migration-runtime.ts b/src/plugin-sdk/memory-migration-runtime.ts new file mode 100644 index 000000000000..d34feb20d23d --- /dev/null +++ b/src/plugin-sdk/memory-migration-runtime.ts @@ -0,0 +1,58 @@ +import { + listAgentIds, + readAgentRosterProperty, + resolveAgentConfig, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope-config.js"; +// Private runtime facade for the Memory Core doctor migration preview. +import type { OpenClawConfig } from "../config/types.js"; +import { isValidAgentId } from "../routing/session-key.js"; + +export type MemoryMigrationAgentWorkspace = Readonly<{ + agentId: string; + sandboxed: boolean; + workspaceDir: string; +}>; + +export type MemoryMigrationAgentWorkspaces = + | Readonly<{ kind: "invalid-agent" }> + | Readonly<{ kind: "resolved"; agents: readonly MemoryMigrationAgentWorkspace[] }>; + +function rawRosterAgentIds(config: OpenClawConfig): string[] { + const roster = readAgentRosterProperty(config); + if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") { + return Object.keys(roster.value); + } + if (roster?.kind !== "list" || !Array.isArray(roster.value)) { + return []; + } + return roster.value.flatMap((entry) => { + const agentId = entry && typeof entry === "object" ? (entry as { id?: unknown }).id : undefined; + return typeof agentId === "string" ? [agentId] : []; + }); +} + +/** + * Resolves the whole roster before a migration preview touches agent-owned paths. + * Keep this private because roster precedence and workspace layout are host policy, + * not a general plugin contract. + */ +export function resolveMemoryMigrationAgentWorkspaces( + config: OpenClawConfig, +): MemoryMigrationAgentWorkspaces { + if (rawRosterAgentIds(config).some((agentId) => !isValidAgentId(agentId))) { + return { kind: "invalid-agent" }; + } + try { + return { + kind: "resolved", + agents: listAgentIds(config).map((agentId) => ({ + agentId, + sandboxed: resolveAgentConfig(config, agentId)?.sandbox?.mode === "all", + workspaceDir: resolveAgentWorkspaceDir(config, agentId), + })), + }; + } catch { + return { kind: "invalid-agent" }; + } +} diff --git a/src/plugin-sdk/routing.ts b/src/plugin-sdk/routing.ts index ac67e24c5606..a7e4d765e7c0 100644 --- a/src/plugin-sdk/routing.ts +++ b/src/plugin-sdk/routing.ts @@ -19,7 +19,6 @@ export { isAcpSessionKey, isIncognitoSessionKey, isSubagentSessionKey, - isValidAgentId, normalizeAccountId, normalizeAgentId, normalizeAgentIdStrict,