fix(memory): authorize cutover memory flushes

This commit is contained in:
Galin Iliev 2026-07-30 19:51:21 +03:00
parent 711380d7e5
commit e2e1488a77
13 changed files with 483 additions and 40 deletions

View file

@ -242,6 +242,180 @@ describe("builtin authorized scoped memory runtime", () => {
).toEqual({ decision: "committed", state: "delivered" });
});
it.each([
{
label: "user",
store: {
scopeKind: "user" as const,
audienceKind: "user" as const,
audienceId: "principal-owner",
authorityKind: "user" as const,
authorityOwnerId: "principal-owner",
},
context: {},
},
{
label: "conversation",
store: {
scopeKind: "conversation" as const,
audienceKind: "conversation" as const,
audienceId: "conversation-1",
authorityKind: "conversation" as const,
authorityOwnerId: "conversation-1",
},
context: {
subject: {
version: 1 as const,
kind: "conversation" as const,
conversationPrincipalId: "conversation-1",
channel: "telegram",
accountId: "default",
},
actor: {
kind: "unattributed" as const,
transportAuditRef: "transport-audit-1",
evidenceRevision: "conversation-evidence-1",
},
verifiedPrincipals: [],
conversation: {
conversationPrincipalId: "conversation-1",
channel: "telegram",
accountId: "default",
evidenceRevision: "conversation-evidence-1",
},
delivery: {
sinkKind: "channel" as const,
audiences: [{ kind: "conversation" as const, id: "conversation-1" }],
egressCapabilityIds: ["reply.final"],
egressRegistryRevision: "conversation-egress-1",
deliveryRevision: "conversation-delivery-1",
},
},
},
...(["agent", "service"] as const).map((kind) => ({
label: kind,
store: {
scopeKind: "agent" as const,
audienceKind: "agent" as const,
audienceId: "main",
authorityKind: "agent" as const,
authorityOwnerId: "main",
},
context: {
subject: { version: 1 as const, kind, principalId: "main" },
actor: {
kind: "principal" as const,
actorKind: kind,
principalId: "main",
assurance: "service" as const,
evidenceRevision: `${kind}-evidence-1`,
},
verifiedPrincipals: [
{
principalId: "main",
assurance: "service" as const,
evidenceRevision: `${kind}-evidence-1`,
},
],
delivery: {
sinkKind: "internal" as const,
audiences: [{ kind: "agent" as const, id: "main" }],
egressCapabilityIds: ["reply.final"],
egressRegistryRevision: `${kind}-egress-1`,
deliveryRevision: `${kind}-delivery-1`,
},
},
})),
])(
"writes a $label maintenance note only to its subject-selected store",
async ({ store, context }) => {
const selectedStore = createBuiltinScopedMemoryStore({
agentId: "main",
...store,
defaultCapabilities: ["append"],
actor: { kind: "human", id: "principal-owner" },
reason: "maintenance target matrix",
nowMs: 1_000,
});
const runtime = createBuiltinScopedMemoryRuntime({ now: () => NOW_MS });
const writeContext = createContext({ ...context, operation: "append" });
const plan = await runtime.authorize(writeContext);
const result = await runtime.writeAuthorized({
context: writeContext,
plan,
mutation: {
version: 1,
kind: "remember",
mutationId: `maintenance-${store.audienceKind}`,
idempotencyKey: `maintenance-${store.audienceKind}`,
content: "authorized maintenance note",
contentType: "markdown",
},
});
const revisionId = result.resourceHandle?.resourceRevision;
if (!revisionId) {
throw new Error("expected remembered resource revision");
}
const database = openOpenClawAgentDatabase({ agentId: "main" }).db;
expect(
database
.prepare(
"SELECT resource.store_id AS store_id FROM memory_resources AS resource INNER JOIN memory_resource_revisions AS revision ON revision.resource_id = resource.resource_id WHERE revision.revision_id = ?",
)
.get(revisionId),
).toEqual({ store_id: selectedStore.storeId });
},
);
it("rejects a maintenance write for an ambiguous subject", async () => {
createBuiltinScopedMemoryStore({
agentId: "main",
scopeKind: "agent",
audienceKind: "agent",
audienceId: "main",
authorityKind: "agent",
authorityOwnerId: "main",
defaultCapabilities: ["append"],
actor: { kind: "human", id: "principal-owner" },
reason: "ambiguous maintenance denial",
nowMs: 1_000,
});
const runtime = createBuiltinScopedMemoryRuntime({ now: () => NOW_MS });
const context = createContext({
operation: "append",
subject: { version: 1, kind: "ambiguous", reason: "unbound" },
actor: {
kind: "unattributed",
transportAuditRef: "ambiguous-audit-1",
evidenceRevision: "ambiguous-evidence-1",
},
verifiedPrincipals: [],
delivery: {
sinkKind: "internal",
audiences: [{ kind: "agent", id: "main" }],
egressCapabilityIds: ["reply.final"],
egressRegistryRevision: "ambiguous-egress-1",
deliveryRevision: "ambiguous-delivery-1",
},
});
const plan = await runtime.authorize(context);
await expect(
runtime.writeAuthorized({
context,
plan,
mutation: {
version: 1,
kind: "remember",
mutationId: "ambiguous-maintenance",
idempotencyKey: "ambiguous-maintenance",
content: "must not persist",
contentType: "markdown",
},
}),
).rejects.toThrow("authorized memory mutation is unavailable");
});
it.each(["pending", "renamed", "activated", "indexed"] as const)(
"recovers a valid interrupted write after the %s boundary",
async (interruptionPoint) => {

View file

@ -1968,7 +1968,9 @@ export function createBuiltinScopedMemoryRuntime(
const importAuthorized = async (params: {
context: MemoryAccessContext;
plan: AuthorizedMemoryPlan;
mutation: Extract<AuthorizedMemoryMutation, { kind: "import" }>;
mutation: Extract<AuthorizedMemoryMutation, { kind: "import" | "deposit" }> & {
kind: "import";
};
}): Promise<MemoryWriteResult> => await writeAuthorized(params);
const syncAuthorized = async (params: {

View file

@ -427,7 +427,9 @@ export interface AuthorizedMemoryRuntime {
importAuthorized(params: {
context: MemoryAccessContext;
plan: AuthorizedMemoryPlan;
mutation: Extract<AuthorizedMemoryMutation, { kind: "import" }>;
mutation: Extract<AuthorizedMemoryMutation, { kind: "import" | "deposit" }> & {
kind: "import";
};
}): Promise<MemoryWriteResult>;
syncAuthorized(params: {
context: MemoryAccessContext;

View file

@ -23,6 +23,10 @@ import {
resolveMediaReferenceSandboxPath,
} from "../media/media-reference.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import {
rememberAuthorizedMemoryForInvocation,
type MemoryInvocationToken,
} from "../plugins/memory-invocation.js";
import { clampNumber } from "../utils.js";
import {
REQUIRED_PARAM_GROUPS,
@ -749,6 +753,51 @@ export function wrapToolMemoryFlushAppendOnlyWrite(
};
}
/** Rebinds the legacy write name to a subject-selected authorized memory mutation. */
export function wrapToolMemoryFlushAuthorizedWrite(
tool: AnyAgentTool,
options: { memoryInvocationToken: MemoryInvocationToken },
): AnyAgentTool {
return {
...tool,
description:
"Record durable memory for this session. The authorized store is selected automatically; provide content only and never a filesystem path.",
parameters: {
type: "object",
properties: {
content: {
type: "string",
description: "Durable memory to record for the current authorized session subject.",
},
},
required: ["content"],
additionalProperties: false,
},
execute: async (toolCallId, args) => {
const record = getToolParamsRecord(args);
const content = typeof record?.content === "string" ? record.content : undefined;
if (!content?.trim()) {
throw new Error("Authorized memory flush requires non-empty content.");
}
if (record && Object.hasOwn(record, "path")) {
throw new Error("Authorized memory flush does not accept a filesystem path.");
}
const result = await rememberAuthorizedMemoryForInvocation({
token: options.memoryInvocationToken,
content,
toolCallId,
});
if ("unavailable" in result) {
throw new Error("Authorized memory flush is unavailable.");
}
return {
content: [{ type: "text", text: "Recorded durable memory." }],
details: { authorizedMemory: true, status: result.status },
};
},
};
}
function isSandboxRootEscapeError(error: unknown): error is Error {
return error instanceof Error && /^Path escapes sandbox root \(/i.test(error.message);
}

View file

@ -56,6 +56,7 @@ import {
createSandboxedWriteTool,
wrapReadToolWithSkillContent,
wrapReadToolWithMemoryVirtualFilesystem,
wrapToolMemoryFlushAuthorizedWrite,
wrapToolMemoryFlushAppendOnlyWrite,
wrapToolWorkspaceRootGuard,
wrapToolWorkspaceRootGuardWithOptions,
@ -132,7 +133,7 @@ import {
} from "./tools/cron-tool.js";
import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js";
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
const LEGACY_MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
type GuardContainerMount = {
containerRoot: string;
@ -511,10 +512,13 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const isMemoryFlushRun = options?.trigger === "memory";
if (isMemoryFlushRun && !options?.memoryFlushWritePath) {
const usesAuthorizedMemoryFlush =
isMemoryFlushRun && isMemoryInvocationEnforced(options?.memoryInvocationToken);
if (isMemoryFlushRun && !usesAuthorizedMemoryFlush && !options?.memoryFlushWritePath) {
throw new Error("memoryFlushWritePath required for memory-triggered tool runs");
}
const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined;
const memoryFlushWritePath =
isMemoryFlushRun && !usesAuthorizedMemoryFlush ? options.memoryFlushWritePath : undefined;
const cronSelfRemoveOnlyJobId =
options?.trigger === "cron" && options.jobId?.trim() ? options.jobId.trim() : undefined;
// Prefer the already-resolved sandbox context policy. Recomputing from
@ -1146,13 +1150,27 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
options?.swarmCollector && options.swarmOutputSchema
? tools.find((tool) => tool.name === "structured_output")
: undefined;
const toolsForMemoryFlush: AnyAgentTool[] = isMemoryFlushRun && memoryFlushWritePath ? [] : tools;
if (isMemoryFlushRun && memoryFlushWritePath) {
const toolsForMemoryFlush: AnyAgentTool[] = isMemoryFlushRun ? [] : tools;
if (isMemoryFlushRun) {
for (const tool of tools) {
if (!MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) {
const allowTool = usesAuthorizedMemoryFlush
? tool.name === "write"
: LEGACY_MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name);
if (!allowTool) {
continue;
}
if (tool.name === "write") {
if (usesAuthorizedMemoryFlush && options?.memoryInvocationToken) {
toolsForMemoryFlush.push(
wrapToolMemoryFlushAuthorizedWrite(tool, {
memoryInvocationToken: options.memoryInvocationToken,
}),
);
continue;
}
if (!memoryFlushWritePath) {
continue;
}
toolsForMemoryFlush.push(
wrapToolMemoryFlushAppendOnlyWrite(tool, {
root: memoryFlushWriteRoot,
@ -1169,8 +1187,9 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
toolsForMemoryFlush.push(tool);
}
}
const unavailableCoreToolReason =
isMemoryFlushRun && memoryFlushWritePath
const unavailableCoreToolReason = usesAuthorizedMemoryFlush
? "memory-triggered compaction runs expose only authorized memory write"
: isMemoryFlushRun && memoryFlushWritePath
? "memory-triggered compaction runs expose only read and append-only write"
: undefined;
const toolsForMessageProvider = filterToolsByMessageProvider(

View file

@ -321,6 +321,7 @@ async function runEmbeddedAgentInternal(
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
trigger: params.trigger,
});
}

View file

@ -322,6 +322,10 @@ export function prepareEmbeddedAttemptToolBase(params: {
const memoryIsolatedTools = isMemoryInvocationEnforced(attempt.memoryInvocationToken)
? constructedToolsRaw.filter((tool) => {
const name = tool.name.toLowerCase();
const isAuthorizedMemoryFlushWrite = attempt.trigger === "memory" && name === "write";
if (isAuthorizedMemoryFlushWrite) {
return true;
}
return !(
[
"write",

View file

@ -8,6 +8,12 @@ import { describe, expect, it, vi } from "vitest";
import type { AnyAgentTool } from "../../agent-tools.types.js";
import { buildEmbeddedAttemptToolRunContext } from "./attempt.tool-run-context.js";
const rememberAuthorizedMemoryForInvocationMock = vi.hoisted(() => vi.fn());
vi.mock("../../../plugins/memory-invocation.js", () => ({
rememberAuthorizedMemoryForInvocation: rememberAuthorizedMemoryForInvocationMock,
}));
const MEMORY_RELATIVE_PATH = "memory/2026-03-24.md";
function createAttemptParams(workspaceDir: string) {
@ -111,4 +117,49 @@ describe("runEmbeddedAttempt memory flush tool forwarding", () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("rebinds enforced memory flush writes to the authorized broker without a path", async () => {
const { wrapToolMemoryFlushAuthorizedWrite } = await import("../../agent-tools.read.js");
const fallbackWrite = vi.fn(async () => {
throw new Error("authorized wrapper should not delegate to the base write tool");
});
const writeTool: AnyAgentTool = {
name: "write",
label: "write",
description: "Write content to a file.",
parameters: { type: "object", properties: {} },
execute: fallbackWrite,
};
const token = {} as never;
rememberAuthorizedMemoryForInvocationMock.mockReset().mockResolvedValue({
status: "committed",
committedAt: "2026-07-30T12:00:00.000Z",
});
const wrapped = wrapToolMemoryFlushAuthorizedWrite(writeTool, {
memoryInvocationToken: token,
});
expect(wrapped.parameters).toMatchObject({
required: ["content"],
properties: { content: { type: "string" } },
});
await expect(
wrapped.execute("call-authorized-flush", { content: "durable note" }),
).resolves.toMatchObject({
content: [{ type: "text", text: "Recorded durable memory." }],
details: { authorizedMemory: true, status: "committed" },
});
expect(rememberAuthorizedMemoryForInvocationMock).toHaveBeenCalledWith({
token,
content: "durable note",
toolCallId: "call-authorized-flush",
});
await expect(
wrapped.execute("call-authorized-flush-path", {
path: MEMORY_RELATIVE_PATH,
content: "durable note",
}),
).rejects.toThrow("Authorized memory flush does not accept a filesystem path.");
expect(fallbackWrite).not.toHaveBeenCalled();
});
});

View file

@ -29,6 +29,12 @@ import { setAgentRunnerMemoryTestDeps } from "./agent-runner-memory.test-support
import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js";
import type { ReplyOperation } from "./reply-run-registry.js";
const cutoverMocks = vi.hoisted(() => ({
isMemoryIsolationCutoverAgent: vi.fn<(agentId: string) => boolean>(() => false),
}));
vi.mock("../../plugins/memory-cutover.js", () => cutoverMocks);
const compactEmbeddedAgentSessionMock = vi.fn();
const runWithModelFallbackMock = vi.fn();
const runEmbeddedAgentEntryMock = vi.fn();
@ -226,6 +232,8 @@ describe("runMemoryFlushIfNeeded", () => {
let rootDir = "";
beforeEach(async () => {
cutoverMocks.isMemoryIsolationCutoverAgent.mockReset();
cutoverMocks.isMemoryIsolationCutoverAgent.mockReturnValue(false);
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-unit-"));
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
@ -437,6 +445,55 @@ describe("runMemoryFlushIfNeeded", () => {
expect(persisted.memoryFlush).toEqual({ kind: "succeeded", compactionCount: 1 });
});
it("uses the authorized write path after cutover in a read-only sandbox", async () => {
cutoverMocks.isMemoryIsolationCutoverAgent.mockReturnValue(true);
const sessionEntry: SessionEntry = {
sessionId: "authorized-flush-session",
updatedAt: Date.now(),
totalTokens: 80_000,
compactionCount: 1,
};
const followupRun = createTestFollowupRun();
await expect(
runMemoryFlushIfNeeded({
cfg: {
agents: {
defaults: {
sandbox: { mode: "non-main", scope: "agent", workspaceAccess: "ro" },
compaction: { memoryFlush: {} },
},
},
},
followupRun: {
...followupRun,
run: {
...followupRun.run,
sessionKey: "agent:main:main",
runtimePolicySessionKey: "agent:main:telegram:default:direct:12345",
},
},
sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext,
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
sessionEntry,
sessionStore: { "agent:main:main": sessionEntry },
sessionKey: "agent:main:main",
runtimePolicySessionKey: "agent:main:telegram:default:direct:12345",
isHeartbeat: false,
replyOperation: createReplyOperation(),
}),
).resolves.toMatchObject({ outcome: "completed" });
const flushCall = requireEmbeddedAgentCall();
expect(flushCall.memoryFlushWritePath).toBeUndefined();
expect(flushCall.prompt).toContain("authorized memory store");
expect(flushCall.prompt).not.toContain("memory/");
expect(flushCall.extraSystemPrompt).toContain("does not accept filesystem paths");
expect(ensureMemoryFlushTargetFileMock).not.toHaveBeenCalled();
});
it("records the least-trusted provenance across a multi-write flush", async () => {
const recordWriteProvenance = vi.fn(async () => {});
registerMemoryFlushPlanResolverForTest(() => ({

View file

@ -93,6 +93,18 @@ type UpdateSessionEntryParams = {
const MAX_VISIBLE_MEMORY_FLUSH_ERROR_CHARS = 600;
const MAX_FLUSH_FAILURES = 3;
const MAX_FLUSH_ERROR_LENGTH = 200;
const AUTHORIZED_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
"Capture durable memories with the write tool.",
"The authorized memory store is selected from the current session subject; provide content only and never a filesystem path.",
"If nothing should be stored, reply with NO_REPLY.",
].join(" ");
const AUTHORIZED_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; record durable memories only through the authorized write tool.",
"The tool selects the store from the current session subject and does not accept filesystem paths.",
"You may reply, but usually NO_REPLY is correct.",
].join(" ");
const embeddedAgentRuntimeLoader = createLazyImportLoader<EmbeddedAgentRuntime>(
() => import("../../agents/embedded-agent.js"),
@ -1210,15 +1222,18 @@ export async function runMemoryFlushIfNeeded(params: {
const memoryAgentId = params.sessionKey
? resolveAgentIdFromSessionKey(params.sessionKey, configuredAgentId)
: configuredAgentId;
if (isMemoryIsolationCutoverAgent(memoryAgentId)) {
return { sessionEntry: params.sessionEntry, outcome: "skipped" };
}
const usesAuthorizedMemoryFlush = isMemoryIsolationCutoverAgent(memoryAgentId);
const memoryFlushPlan = resolveMemoryFlushPlan({ cfg: params.cfg });
if (!memoryFlushPlan) {
return { sessionEntry: params.sessionEntry, outcome: "skipped" };
}
const memoryFlushWritable = (() => {
// The authorized path commits through the broker, never the workspace.
// Keep the sandbox write gate only for legacy file-backed flushes.
if (usesAuthorizedMemoryFlush) {
return true;
}
if (!params.sessionKey) {
return true;
}
@ -1455,29 +1470,36 @@ export async function runMemoryFlushIfNeeded(params: {
cfg: params.cfg,
nowMs: memoryFlushNowMs,
}) ?? memoryFlushPlan;
const memoryFlushWritePath = activeMemoryFlushPlan.relativePath;
await memoryDeps.ensureMemoryFlushTargetFile({
workspaceDir: params.followupRun.run.workspaceDir,
relativePath: memoryFlushWritePath,
});
const memoryFlushAbsolutePath = path.join(
params.followupRun.run.workspaceDir,
memoryFlushWritePath,
);
const readMemoryFlushContent = () =>
fs.promises.readFile(memoryFlushAbsolutePath, "utf8").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
const memoryFlushWritePath = usesAuthorizedMemoryFlush
? undefined
: activeMemoryFlushPlan.relativePath;
if (memoryFlushWritePath) {
await memoryDeps.ensureMemoryFlushTargetFile({
workspaceDir: params.followupRun.run.workspaceDir,
relativePath: memoryFlushWritePath,
});
}
const memoryFlushAbsolutePath = memoryFlushWritePath
? path.join(params.followupRun.run.workspaceDir, memoryFlushWritePath)
: undefined;
const readMemoryFlushContent = () =>
memoryFlushAbsolutePath
? fs.promises.readFile(memoryFlushAbsolutePath, "utf8").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
})
: Promise.resolve("");
// Capture one baseline before any write can start. Per-write snapshots can
// pair a failed later write with an earlier success and miss mixed content.
const memoryFlushContentBefore = await readMemoryFlushContent();
let memoryFlushWroteTarget = false;
const flushSystemPrompt = [
params.followupRun.run.extraSystemPrompt,
activeMemoryFlushPlan.systemPrompt,
usesAuthorizedMemoryFlush
? AUTHORIZED_MEMORY_FLUSH_SYSTEM_PROMPT
: activeMemoryFlushPlan.systemPrompt,
]
.filter(Boolean)
.join("\n\n");
@ -1560,8 +1582,10 @@ export async function runMemoryFlushIfNeeded(params: {
allowGatewaySubagentBinding: true,
silentExpected: true,
trigger: "memory",
memoryFlushWritePath,
prompt: activeMemoryFlushPlan.prompt,
...(memoryFlushWritePath ? { memoryFlushWritePath } : {}),
prompt: usesAuthorizedMemoryFlush
? AUTHORIZED_MEMORY_FLUSH_PROMPT
: activeMemoryFlushPlan.prompt,
transcriptPrompt: "",
extraSystemPrompt: flushSystemPrompt,
isFinalFallbackAttempt: runOptions.isFinalFallbackAttempt,
@ -1571,7 +1595,7 @@ export async function runMemoryFlushIfNeeded(params: {
abortSignal: params.replyOperation.abortSignal,
replyOperation: params.replyOperation,
onAgentEvent: (evt) => {
if (evt.stream === "tool" && evt.data.name === "write") {
if (!usesAuthorizedMemoryFlush && evt.stream === "tool" && evt.data.name === "write") {
if (evt.data.phase === "result" && evt.data.isError !== true) {
memoryFlushWroteTarget = true;
}
@ -1594,7 +1618,11 @@ export async function runMemoryFlushIfNeeded(params: {
return result;
},
});
if (activeMemoryFlushPlan.recordWriteProvenance && memoryFlushWroteTarget) {
if (
activeMemoryFlushPlan.recordWriteProvenance &&
memoryFlushWritePath &&
memoryFlushWroteTarget
) {
await activeMemoryFlushPlan.recordWriteProvenance({
workspaceDir: params.followupRun.run.workspaceDir,
relativePath: memoryFlushWritePath,

View file

@ -11,7 +11,10 @@ import type {
import type { DB as OpenClawAgentDatabaseSchema } from "../state/openclaw-agent-db.generated.js";
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
import { ensureOpenClawAgentScopedMemorySchema } from "../state/openclaw-agent-scoped-memory-schema.js";
import type { AdmittedAuthorizedMemoryReadRuntime } from "./memory-authorization-runtime.js";
import type {
AdmittedAuthorizedMemoryReadRuntime,
AdmittedAuthorizedMemoryRuntime,
} from "./memory-authorization-runtime.js";
import {
canonicalMemoryAudiencesJson,
canonicalMemoryStringArrayJson,
@ -78,7 +81,7 @@ export type MemoryInvocationState = {
}>;
context?: MemoryAccessContext;
plan?: AuthorizedMemoryPlan;
runtime?: AdmittedAuthorizedMemoryReadRuntime;
runtime?: AdmittedAuthorizedMemoryReadRuntime | AdmittedAuthorizedMemoryRuntime;
runExposure?: TranscriptMemoryRunExposureSnapshot;
transcriptPolicy?: PreparedMemoryTranscriptPolicy;
virtualFilesystem?: MemoryVirtualFilesystemView;

View file

@ -10,6 +10,8 @@ import type {
AuthorizedMemoryPlan,
MemoryAccessContext,
MemoryActorEvidence,
MemoryOperation,
MemoryWriteResult,
VerifiedPrincipalRef,
} from "../memory-host-sdk/host/authorization.js";
import type {
@ -160,6 +162,7 @@ function buildAuthorityFacts(params: {
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
operation?: MemoryOperation;
}): MemoryAccessContextFacts | undefined {
const current = readCurrentSessionMemorySubjectAuthority({
agentId: params.agentId,
@ -292,7 +295,7 @@ function buildAuthorityFacts(params: {
},
collaboration: { kind: "not-applicable" },
verifiedMemberships: [],
operation: "read",
operation: params.operation ?? "read",
hostFactsRevision,
};
}
@ -318,6 +321,7 @@ function revalidateInvocation(token: MemoryInvocationToken, state: MemoryInvocat
sessionKey: state.sessionKey,
runId: state.runId,
...state.deliveryInput,
operation: state.context?.operation,
});
if (!facts) {
return false;
@ -370,6 +374,7 @@ export async function initializeMemoryInvocation(params: {
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
trigger?: string;
}): Promise<void> {
const state = invocationStateByToken.get(params.token);
if (
@ -396,7 +401,10 @@ export async function initializeMemoryInvocation(params: {
...(params.messageTo !== undefined ? { messageTo: params.messageTo } : {}),
...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}),
});
const facts = buildAuthorityFacts(params);
const facts = buildAuthorityFacts({
...params,
operation: params.trigger === "memory" ? "append" : "read",
});
if (!facts) {
state.initialization = "unavailable";
return;
@ -561,6 +569,54 @@ export async function readAuthorizedMemoryForInvocation(params: {
}
}
/** Commits one maintenance memory note without exposing a filesystem destination. */
export async function rememberAuthorizedMemoryForInvocation(params: {
token: MemoryInvocationToken;
content: string;
toolCallId: string;
}): Promise<
Readonly<Pick<MemoryWriteResult, "status" | "committedAt">> | MemoryInvocationUnavailable
> {
const state = readInvocationState(params.token);
if (
!state ||
!revalidateInvocation(params.token, state) ||
state.context?.operation !== "append" ||
!state.context ||
!state.plan ||
!state.runtime ||
!("writeAuthorized" in state.runtime) ||
typeof state.runtime.writeAuthorized !== "function" ||
!params.content.trim() ||
!params.toolCallId.trim()
) {
return MEMORY_INVOCATION_UNAVAILABLE;
}
const mutationId = hashMemoryRevision("mfm1", {
contextFingerprint: state.context.contextFingerprint,
planId: state.plan.planId,
toolCallId: params.toolCallId,
content: params.content,
});
try {
const result = await state.runtime.writeAuthorized({
context: state.context,
plan: state.plan,
mutation: {
version: 1,
kind: "remember",
mutationId,
idempotencyKey: mutationId,
content: params.content,
contentType: "markdown",
},
});
return Object.freeze({ status: result.status, committedAt: result.committedAt });
} catch {
return MEMORY_INVOCATION_UNAVAILABLE;
}
}
/** Returns the ephemeral mount plan only while its invocation remains current. */
export function getMemoryVirtualFilesystemView(
token: MemoryInvocationToken | undefined,

View file

@ -462,9 +462,6 @@ export function resolveMemoryFlushPlan(params: {
cfg?: OpenClawConfig;
nowMs?: number;
}): MemoryFlushPlan | null {
if (isMemoryInvocationEnforced()) {
return null;
}
return memoryPluginState.capability?.capability.flushPlanResolver?.(params) ?? null;
}
export function getMemoryRuntime(): MemoryPluginRuntime | undefined {