mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 03:55:46 +00:00
fix(memory): authorize cutover memory flushes
This commit is contained in:
parent
711380d7e5
commit
e2e1488a77
13 changed files with 483 additions and 40 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ async function runEmbeddedAgentInternal(
|
|||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
trigger: params.trigger,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue