From 73fd65c2dffb35c813e628d270d5f6b98ca5c294 Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Fri, 14 Aug 2026 05:23:42 -0700 Subject: [PATCH] feat(memory): bind postbox deposits to verified turns --- docs/plugins/sdk-subpaths.md | 1 + extensions/memory-core/index.test.ts | 31 ++++ extensions/memory-core/index.ts | 59 +++++++ extensions/memory-core/openclaw.plugin.json | 2 +- .../src/memory/scoped-memory-db.ts | 1 + .../src/memory/scoped-memory-sharing.test.ts | 109 +++++++----- .../src/memory/scoped-memory-sharing.ts | 142 ++++++++++++--- package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + ...lugin-sdk-private-local-only-subpaths.json | 1 + src/agents/agent-tools.ts | 39 ++++- .../run/attempt-tool-prepare.ts | 6 +- .../embedded-agent-runner/run/params.ts | 2 + .../run/run-attempt-dispatch.ts | 1 + src/agents/openclaw-tools.plugin-context.ts | 2 + .../reply/agent-runner-embedded-candidate.ts | 38 ++++ .../memory-postbox-turn-capability.test.ts | 124 +++++++++++++ src/gateway/memory-postbox-turn-capability.ts | 164 ++++++++++++++++++ src/plugin-sdk/memory-postbox-runtime.ts | 9 + src/plugins/memory-postbox-tool-binding.ts | 3 + src/plugins/tool-types.ts | 2 + src/plugins/tools.optional.test.ts | 43 +++++ src/plugins/tools.ts | 8 +- src/state/openclaw-agent-schema.sql | 7 +- 24 files changed, 721 insertions(+), 78 deletions(-) create mode 100644 src/gateway/memory-postbox-turn-capability.test.ts create mode 100644 src/gateway/memory-postbox-turn-capability.ts create mode 100644 src/plugin-sdk/memory-postbox-runtime.ts create mode 100644 src/plugins/memory-postbox-tool-binding.ts diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 1c4a7d9085b3..ce2fd42614b1 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -369,6 +369,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | --- | --- | | `plugin-sdk/memory-authorization` | Versioned serializable memory-authorization contracts and selected-capability declarations. This contract alone does not enable isolation, capability admission, or an authorization mode. | | `plugin-sdk/memory-authorization-conformance` | Pure backend conformance helpers for the memory-authorization contract. | + | `plugin-sdk/memory-postbox-runtime` | Private-local runtime resolver and scoped binding keys for core-issued, turn-bound memory postbox source capabilities. | | `plugin-sdk/memory-core-host-embedding-registry` | Private-local after July 2026; Lightweight memory embedding provider registry helpers | | `plugin-sdk/memory-core-host-engine-curated` | Private-local focused curated-memory annotation parsing for doctor and promotion paths | | `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports | diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index 84f3d65e18cb..2b150327188b 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -3,6 +3,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi, OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core"; import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core"; +import { + MEMORY_POSTBOX_RUN_ID_BINDING, + MEMORY_POSTBOX_TURN_CAPABILITY_BINDING, +} from "openclaw/plugin-sdk/memory-postbox-runtime"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MEMORY_CORE_AUTHORIZATION_CAPABILITIES } from "./src/authorization.js"; @@ -289,6 +293,33 @@ describe("memory-core plugin runtime registration", () => { expect(host.remember).toHaveBeenCalledWith({ content: "durable fact" }); }); + it("exposes postbox deposit only with the memory-core-scoped host binding", () => { + const factories = new Map unknown>(); + plugin.register( + createTestPluginApi({ + config: {}, + runtime: hostRuntime, + registerTool(factory, options) { + for (const name of options?.names ?? []) { + factories.set(name, factory as (ctx: never) => unknown); + } + }, + }), + ); + const postbox = factories.get("memory_postbox_deposit"); + expect(postbox?.({ agentId: "main", sessionKey: "session" } as never)).toBeNull(); + expect( + postbox?.({ + agentId: "main", + sessionKey: "session", + toolBindings: { + [MEMORY_POSTBOX_TURN_CAPABILITY_BINDING]: "opaque-turn-capability", + [MEMORY_POSTBOX_RUN_ID_BINDING]: "run-1", + }, + } as never), + ).toMatchObject({ name: "memory_postbox_deposit" }); + }); + it("keeps memory manager initialization demand-driven", () => { plugin.register( createTestPluginApi({ diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index caaf8c4fdd90..7dc339ee674e 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -8,6 +8,10 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import { + MEMORY_POSTBOX_RUN_ID_BINDING, + MEMORY_POSTBOX_TURN_CAPABILITY_BINDING, +} from "openclaw/plugin-sdk/memory-postbox-runtime"; import { definePluginEntry, type AnyAgentTool, @@ -58,6 +62,9 @@ const loadStandingIntentToolModule = createLazyRuntimeModule( const loadRuntimeProviderModule = createLazyRuntimeModule( () => import("./src/runtime-provider.js"), ); +const loadScopedMemorySharingModule = createLazyRuntimeModule( + () => import("./src/memory/scoped-memory-sharing.js"), +); function getToolConfig(options: MemoryToolOptions): OpenClawConfig | undefined { return options.getConfig?.() ?? options.config; @@ -110,6 +117,15 @@ const MemoryRememberSchema = { additionalProperties: false, } as const satisfies TSchema; +const MemoryPostboxDepositSchema = { + type: "object", + properties: { + content: { type: "string" }, + }, + required: ["content"], + additionalProperties: false, +} as const satisfies TSchema; + function createLazyMemoryTool(params: { options: MemoryToolOptions; label: string; @@ -202,6 +218,45 @@ function createSubjectMemoryRememberTool(ctx: OpenClawPluginToolContext): AnyAge }; } +/** A host-scoped turn capability makes postbox deposit a one-way, source-bound operation. */ +function createMemoryPostboxDepositTool(ctx: OpenClawPluginToolContext): AnyAgentTool | null { + const turnCapability = ctx.toolBindings?.[MEMORY_POSTBOX_TURN_CAPABILITY_BINDING]; + const runId = ctx.toolBindings?.[MEMORY_POSTBOX_RUN_ID_BINDING]; + const agentId = ctx.agentId?.trim(); + const sessionKey = ctx.sessionKey?.trim(); + if (typeof turnCapability !== "string" || typeof runId !== "string" || !agentId || !sessionKey) { + return null; + } + return { + label: "Memory Postbox Deposit", + name: "memory_postbox_deposit", + description: + "Submit a current-turn observation to the authorized memory postbox. Provide only the observation. The result only confirms acceptance; it cannot list, read, or identify postbox items.", + parameters: MemoryPostboxDepositSchema, + execute: async (_toolCallId, params) => { + const content = + params && typeof params === "object" && "content" in params + ? (params as { content?: unknown }).content + : undefined; + if (typeof content !== "string" || !content.trim()) { + return jsonResult({ accepted: false }); + } + const { depositBuiltinMemoryPostboxFromTurnCapability } = + await loadScopedMemorySharingModule(); + return jsonResult( + depositBuiltinMemoryPostboxFromTurnCapability({ + agentId, + runId, + sessionKey, + ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}), + turnCapability, + content, + }), + ); + }, + }; +} + function createLazyStandingIntentTool( ctx: OpenClawPluginToolContext, reportUnavailable: (reason: string) => void, @@ -388,6 +443,10 @@ export default definePluginEntry({ { names: ["intent"] }, ); + api.registerTool((ctx) => createMemoryPostboxDepositTool(ctx), { + names: ["memory_postbox_deposit"], + }); + api.on("before_prompt_build", async (event, ctx) => { if (ctx.memoryReadEnforced) { return undefined; diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index ecaa61d8a6b7..540c2d82e5db 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -16,7 +16,7 @@ }, "kind": "memory", "contracts": { - "tools": ["intent", "memory_get", "memory_search"] + "tools": ["intent", "memory_get", "memory_postbox_deposit", "memory_search"] }, "toolMetadata": { "intent": { diff --git a/extensions/memory-core/src/memory/scoped-memory-db.ts b/extensions/memory-core/src/memory/scoped-memory-db.ts index 66885477d4a0..bd9ab2189963 100644 --- a/extensions/memory-core/src/memory/scoped-memory-db.ts +++ b/extensions/memory-core/src/memory/scoped-memory-db.ts @@ -271,6 +271,7 @@ export type MemoryProjectionRow = { export type MemoryPostboxSettingRow = { agent_id: string; + target_store_id: string; mode: "off" | "review-required"; updated_by_principal_id: string; updated_at: number; diff --git a/extensions/memory-core/src/memory/scoped-memory-sharing.test.ts b/extensions/memory-core/src/memory/scoped-memory-sharing.test.ts index d89c7a49f87a..a621ee08457a 100644 --- a/extensions/memory-core/src/memory/scoped-memory-sharing.test.ts +++ b/extensions/memory-core/src/memory/scoped-memory-sharing.test.ts @@ -3,6 +3,10 @@ import os from "node:os"; import path from "node:path"; import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + mintMemoryPostboxTurnCapability, + resetMemoryPostboxTurnCapabilitiesForTest, +} from "../../../../src/gateway/memory-postbox-turn-capability.js"; import { openOpenClawAgentDatabase } from "../../../../src/state/openclaw-agent-db.js"; import { createBuiltinScopedMemoryResource, @@ -11,9 +15,9 @@ import { import { createBuiltinMemoryProjection, depositBuiltinMemoryPostbox, + depositBuiltinMemoryPostboxFromTurnCapability, expireBuiltinMemoryProjections, refreshBuiltinMemoryProjection, - issueBuiltinMemoryPostboxSourceHandle, registerBuiltinMemoryProjectionTarget, revokeBuiltinMemoryProjection, reviewBuiltinMemoryPostboxItem, @@ -34,6 +38,7 @@ describe("builtin scoped memory sharing", () => { afterEach(() => { closeOpenClawAgentDatabasesForTest(); + resetMemoryPostboxTurnCapabilitiesForTest(); vi.unstubAllEnvs(); fs.rmSync(stateDir, { recursive: true, force: true }); }); @@ -106,6 +111,29 @@ describe("builtin scoped memory sharing", () => { }); } + function depositFromVerifiedTurn(params: { content: string; sourceMessageRef: string }) { + const runId = `run-${params.sourceMessageRef}`; + const sessionKey = "agent:main:direct:alice"; + const token = mintMemoryPostboxTurnCapability({ + agentId, + runId, + sessionKey, + sessionId: "session-alice", + sourceChannelRef: "telegram:alice", + sourceMessageRef: params.sourceMessageRef, + senderEvidenceRef: "telegram:sender-alice", + targetPrincipalId: alice, + }); + return depositBuiltinMemoryPostboxFromTurnCapability({ + agentId, + runId, + sessionKey, + sessionId: "session-alice", + turnCapability: token, + content: params.content, + }); + } + it("creates a reviewed target copy with source lineage and no private read grant", () => { const source = sourceStore(); const target = projectionStore(); @@ -152,7 +180,9 @@ describe("builtin scoped memory sharing", () => { const database = openOpenClawAgentDatabase({ agentId }); expect( database.db - .prepare("SELECT source_revision_id, reviewed_by_principal_id, expiry_kind FROM memory_projections") + .prepare( + "SELECT source_revision_id, reviewed_by_principal_id, expiry_kind FROM memory_projections", + ) .get(), ).toEqual({ source_revision_id: privateRevision.revisionId, @@ -350,7 +380,9 @@ describe("builtin scoped memory sharing", () => { }), ).toBeUndefined(); expect( - database.db.prepare("SELECT state FROM memory_projections WHERE projection_id = ?").get(projection.projectionId), + database.db + .prepare("SELECT state FROM memory_projections WHERE projection_id = ?") + .get(projection.projectionId), ).toEqual({ state: "expired" }); }); @@ -422,15 +454,9 @@ describe("builtin scoped memory sharing", () => { it("keeps postbox off by default and makes review-required deposits one-way", () => { const target = privateTargetStore(); - const offHandle = issueBuiltinMemoryPostboxSourceHandle({ - agentId, - sourceSessionId: "group-session", - sourceChannelRef: "channel-ref", - sourceMessageRef: "message-ref-off", - senderEvidenceRef: "sender-proof", - targetStoreId: target.storeId, - }); - expect(depositBuiltinMemoryPostbox({ agentId, sourceHandleId: offHandle, content: "observation" })).toEqual({ accepted: false }); + expect( + depositFromVerifiedTurn({ content: "observation", sourceMessageRef: "message-ref-off" }), + ).toEqual({ accepted: false }); setBuiltinMemoryPostboxMode({ agentId, @@ -438,30 +464,34 @@ describe("builtin scoped memory sharing", () => { operatorPrincipalId: alice, mode: "review-required", }); - const handle = issueBuiltinMemoryPostboxSourceHandle({ - agentId, - sourceSessionId: "group-session", - sourceChannelRef: "channel-ref", - sourceMessageRef: "message-ref-live", - senderEvidenceRef: "sender-proof", - targetStoreId: target.storeId, - }); - expect(depositBuiltinMemoryPostbox({ agentId, sourceHandleId: "forged", content: "observation" })).toEqual({ accepted: false }); - expect(depositBuiltinMemoryPostbox({ agentId, sourceHandleId: handle, content: "observation" })).toEqual({ accepted: true }); - expect(depositBuiltinMemoryPostbox({ agentId, sourceHandleId: handle, content: "observation" })).toEqual({ accepted: false }); + expect( + depositBuiltinMemoryPostbox({ agentId, sourceHandleId: "forged", content: "observation" }), + ).toEqual({ accepted: false }); + expect( + depositFromVerifiedTurn({ content: "observation", sourceMessageRef: "message-ref-live" }), + ).toEqual({ accepted: true }); const database = openOpenClawAgentDatabase({ agentId }); - const item = database.db.prepare("SELECT item_id, state FROM memory_postbox_items").get() as { item_id: string; state: string }; + const item = database.db.prepare("SELECT item_id, state FROM memory_postbox_items").get() as { + item_id: string; + state: string; + }; expect(item.state).toBe("postbox"); - expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_resources").get()).toEqual({ count: 0 }); + expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_resources").get()).toEqual({ + count: 0, + }); reviewBuiltinMemoryPostboxItem({ agentId, itemId: item.item_id, operatorPrincipalId: alice, decision: "approve", }); - expect(database.db.prepare("SELECT state FROM memory_postbox_items").get()).toEqual({ state: "reviewed" }); - expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_resources").get()).toEqual({ count: 0 }); + expect(database.db.prepare("SELECT state FROM memory_postbox_items").get()).toEqual({ + state: "reviewed", + }); + expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_resources").get()).toEqual({ + count: 0, + }); }); it("enforces the persisted per-channel target cap without exposing deposited content", () => { @@ -473,24 +503,23 @@ describe("builtin scoped memory sharing", () => { mode: "review-required", }); for (let index = 0; index < 13; index += 1) { - const handle = issueBuiltinMemoryPostboxSourceHandle({ - agentId, - sourceSessionId: `source-session-${index}`, - sourceChannelRef: "channel-ref", - sourceMessageRef: `message-${index}`, - senderEvidenceRef: "sender-proof", - targetStoreId: target.storeId, - }); expect( - depositBuiltinMemoryPostbox({ agentId, sourceHandleId: handle, content: `observation-${index}` }), + depositFromVerifiedTurn({ + content: `observation-${index}`, + sourceMessageRef: `message-${index}`, + }), ).toEqual({ accepted: index < 12 }); } const database = openOpenClawAgentDatabase({ agentId }); - expect(database.db.prepare("SELECT deposit_count FROM memory_postbox_rate_limits").get()).toEqual({ + expect( + database.db.prepare("SELECT deposit_count FROM memory_postbox_rate_limits").get(), + ).toEqual({ deposit_count: 12, }); - expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_postbox_items").get()).toEqual({ - count: 12, - }); + expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_postbox_items").get()).toEqual( + { + count: 12, + }, + ); }); }); diff --git a/extensions/memory-core/src/memory/scoped-memory-sharing.ts b/extensions/memory-core/src/memory/scoped-memory-sharing.ts index 035d18518f79..bc6e4d9c1bc3 100644 --- a/extensions/memory-core/src/memory/scoped-memory-sharing.ts +++ b/extensions/memory-core/src/memory/scoped-memory-sharing.ts @@ -1,27 +1,25 @@ import { createHash, randomUUID } from "node:crypto"; +import type { AudienceRef } from "openclaw/plugin-sdk/memory-authorization"; +import { resolveMemoryPostboxTurnCapability } from "openclaw/plugin-sdk/memory-postbox-runtime"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, runSqliteImmediateTransactionSync, } from "openclaw/plugin-sdk/sqlite-runtime"; -import type { AudienceRef } from "openclaw/plugin-sdk/memory-authorization"; +import { type ScopedMemoryDatabase, withScopedMemoryDatabase } from "./scoped-memory-db.js"; +import { evaluateBuiltinScopedMemoryPolicy } from "./scoped-memory-policy.js"; import { createBuiltinScopedMemoryResource, readBuiltinScopedMemoryRevisionSnapshot, type BuiltinScopedMemoryDerivedSource, type BuiltinScopedMemoryRevision, } from "./scoped-memory-resources.js"; -import { evaluateBuiltinScopedMemoryPolicy } from "./scoped-memory-policy.js"; import { createScopedMemorySourcePolicySetId, normalizeScopedMemoryRequiredText, type BuiltinScopedMemoryStore, } from "./scoped-memory-store.js"; -import { - type ScopedMemoryDatabase, - withScopedMemoryDatabase, -} from "./scoped-memory-db.js"; const POSTBOX_HANDLE_TTL_MS = 10 * 60 * 1000; const POSTBOX_RATE_WINDOW_MS = 60 * 60 * 1000; @@ -56,12 +54,11 @@ function normalize(value: string, label: string): string { return normalizeScopedMemoryRequiredText(value, label); } -function targetAudience(params: { kind: ProjectionAudience["kind"]; id: string }): ProjectionAudience { - if ( - params.kind !== "conversation" && - params.kind !== "role" && - params.kind !== "agent-shared" - ) { +function targetAudience(params: { + kind: ProjectionAudience["kind"]; + id: string; +}): ProjectionAudience { + if (params.kind !== "conversation" && params.kind !== "role" && params.kind !== "agent-shared") { throw new Error("memory projection target is unavailable"); } return Object.freeze({ kind: params.kind, id: normalize(params.id, "projection target id") }); @@ -115,6 +112,25 @@ function readTargetStore(params: { ); } +/** The private postbox target is selected from core-verified source identity, never tool input. */ +function readPostboxTargetStore(params: { + database: Parameters>[0]; + agentId: string; + targetPrincipalId: string; +}) { + const db = getNodeSqliteKysely(params.database); + return executeSqliteQueryTakeFirstSync( + params.database, + db + .selectFrom("memory_stores") + .select(["store_id", "audience_kind", "audience_id"]) + .where("agent_id", "=", params.agentId) + .where("audience_kind", "=", "user") + .where("audience_id", "=", params.targetPrincipalId) + .where("lifecycle_state", "=", "active"), + ); +} + function readSource(params: { database: Parameters>[0]; agentId: string; @@ -159,7 +175,10 @@ function readSourceRequirements(params: { if (policyRequirements.length === 0) { throw new Error("memory projection source is unavailable"); } - return Object.freeze({ revisionId: params.revisionId, policyRequirements: Object.freeze(policyRequirements) }); + return Object.freeze({ + revisionId: params.revisionId, + policyRequirements: Object.freeze(policyRequirements), + }); } /** @@ -195,11 +214,7 @@ export function registerBuiltinMemoryProjectionTarget(params: { .where("agent_id", "=", params.agentId) .where("lifecycle_state", "=", "active"), ); - if ( - !store || - store.audience_kind !== target.kind || - store.audience_id !== target.id - ) { + if (!store || store.audience_kind !== target.kind || store.audience_id !== target.id) { throw new Error("memory projection target is unavailable"); } executeSqliteQuerySync( @@ -236,7 +251,9 @@ export function createBuiltinMemoryProjection(params: { purpose: string; preview: string; content: string; - expiry: Readonly<{ kind: "expires"; expiresAt: number }> | Readonly<{ kind: "no-expiry"; auditReason: string }>; + expiry: + | Readonly<{ kind: "expires"; expiresAt: number }> + | Readonly<{ kind: "no-expiry"; auditReason: string }>; nowMs?: number; }): BuiltinMemoryProjection { const target = targetAudience(params.target); @@ -375,7 +392,9 @@ function tombstoneProjectionCopy(params: { ); executeSqliteQuerySync( params.database, - db.deleteFrom("memory_scoped_chunks").where("revision_id", "=", params.projection.copy_revision_id), + db + .deleteFrom("memory_scoped_chunks") + .where("revision_id", "=", params.projection.copy_revision_id), ); executeSqliteQuerySync( params.database, @@ -498,7 +517,9 @@ export function refreshBuiltinMemoryProjection(params: { purpose: string; preview: string; content: string; - expiry: Readonly<{ kind: "expires"; expiresAt: number }> | Readonly<{ kind: "no-expiry"; auditReason: string }>; + expiry: + | Readonly<{ kind: "expires"; expiresAt: number }> + | Readonly<{ kind: "no-expiry"; auditReason: string }>; nowMs?: number; }): Readonly<{ projection: BuiltinMemoryProjection; replacedExposureSetIds: readonly string[] }> { const projectionId = normalize(params.projectionId, "projection id"); @@ -587,12 +608,13 @@ export function setBuiltinMemoryPostboxMode(params: { .insertInto("memory_postbox_settings") .values({ agent_id: params.agentId, + target_store_id: params.targetStoreId, mode: params.mode, updated_by_principal_id: normalize(params.operatorPrincipalId, "operator principal id"), updated_at: nowMs, }) .onConflict((conflict) => - conflict.column("agent_id").doUpdateSet({ + conflict.columns(["agent_id", "target_store_id"]).doUpdateSet({ mode: params.mode, updated_by_principal_id: normalize(params.operatorPrincipalId, "operator principal id"), updated_at: nowMs, @@ -603,7 +625,7 @@ export function setBuiltinMemoryPostboxMode(params: { } /** Only core/channel ingress may mint this opaque handle from current verified sender evidence. */ -export function issueBuiltinMemoryPostboxSourceHandle(params: { +function issueBuiltinMemoryPostboxSourceHandle(params: { agentId: string; sourceSessionId: string; sourceChannelRef: string; @@ -647,6 +669,62 @@ export function issueBuiltinMemoryPostboxSourceHandle(params: { }); } +/** + * Redeems the core-issued turn token before minting the persisted one-use handle. The selected + * memory plugin resolves the core-selected private subject to its quarantine store; tool input cannot. + */ +export function depositBuiltinMemoryPostboxFromTurnCapability(params: { + agentId: string; + runId: string; + sessionKey: string; + sessionId?: string; + turnCapability: string; + content: string; + nowMs?: number; +}): BuiltinMemoryPostboxDeposit { + const source = resolveMemoryPostboxTurnCapability({ + token: params.turnCapability, + agentId: params.agentId, + runId: params.runId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + nowMs: params.nowMs, + }); + if (!source) { + return Object.freeze({ accepted: false }); + } + try { + const targetStoreId = withScopedMemoryDatabase(params.agentId, (database) => { + const target = readPostboxTargetStore({ + database, + agentId: params.agentId, + targetPrincipalId: source.targetPrincipalId, + }); + return target?.store_id; + }); + if (!targetStoreId) { + return Object.freeze({ accepted: false }); + } + const sourceHandleId = issueBuiltinMemoryPostboxSourceHandle({ + agentId: params.agentId, + sourceSessionId: params.sessionId ?? params.sessionKey, + sourceChannelRef: source.sourceChannelRef, + sourceMessageRef: source.sourceMessageRef, + senderEvidenceRef: source.senderEvidenceRef, + targetStoreId, + nowMs: params.nowMs, + }); + return depositBuiltinMemoryPostbox({ + agentId: params.agentId, + sourceHandleId, + content: params.content, + nowMs: params.nowMs, + }); + } catch { + return Object.freeze({ accepted: false }); + } +} + /** The source can only receive accepted/generic-refusal; there is no read, list, or exact-get continuation. */ export function depositBuiltinMemoryPostbox(params: { agentId: string; @@ -675,7 +753,8 @@ export function depositBuiltinMemoryPostbox(params: { db .selectFrom("memory_postbox_settings") .select("mode") - .where("agent_id", "=", params.agentId), + .where("agent_id", "=", params.agentId) + .where("target_store_id", "=", handle?.target_store_id ?? ""), ); if ( !handle || @@ -771,7 +850,13 @@ export function reviewBuiltinMemoryPostboxItem(params: { db .selectFrom("memory_postbox_items as item") .innerJoin("memory_stores as store", "store.store_id", "item.target_store_id") - .select(["item.item_id", "item.state", "item.target_store_id", "store.audience_kind", "store.audience_id"]) + .select([ + "item.item_id", + "item.state", + "item.target_store_id", + "store.audience_kind", + "store.audience_id", + ]) .where("item.item_id", "=", params.itemId) .where("item.agent_id", "=", params.agentId), ); @@ -785,7 +870,12 @@ export function reviewBuiltinMemoryPostboxItem(params: { audience: { kind: item.audience_kind, id: item.audience_id }, operation: "policy-admin", }); - const state = params.decision === "approve" ? "reviewed" : params.decision === "reject" ? "rejected" : "purged"; + const state = + params.decision === "approve" + ? "reviewed" + : params.decision === "reject" + ? "rejected" + : "purged"; executeSqliteQuerySync( database, db diff --git a/package.json b/package.json index 31fb767ecef3..7304ac9d7bab 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "!dist/plugin-sdk/llm.d.ts", "!dist/plugin-sdk/markdown-table-runtime.d.ts", "!dist/plugin-sdk/media-generation-runtime.d.ts", + "!dist/plugin-sdk/memory-postbox-runtime.d.ts", "!dist/plugin-sdk/memory-core-host-embedding-registry.d.ts", "!dist/plugin-sdk/memory-core-host-engine-curated.d.ts", "!dist/plugin-sdk/memory-core-host-engine-embeddings.d.ts", @@ -1214,6 +1215,9 @@ "types": "./dist/plugin-sdk/memory-authorization-conformance.d.ts", "default": "./dist/plugin-sdk/memory-authorization-conformance.js" }, + "./plugin-sdk/memory-postbox-runtime": { + "default": "./dist/plugin-sdk/memory-postbox-runtime.js" + }, "./plugin-sdk/memory-core-host-embedding-registry": { "default": "./dist/plugin-sdk/memory-core-host-embedding-registry.js" }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 407e903eaef0..3918dbcb0050 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -254,6 +254,7 @@ "qa-runner-runtime", "memory-authorization", "memory-authorization-conformance", + "memory-postbox-runtime", "memory-core-host-embedding-registry", "memory-core-host-engine-embeddings", "memory-core-host-engine-curated", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 88a48c6c2447..35de31c56e3e 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -65,6 +65,7 @@ "llm", "markdown-table-runtime", "media-generation-runtime", + "memory-postbox-runtime", "memory-core-host-embedding-registry", "memory-core-host-engine-curated", "memory-core-host-engine-embeddings", diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index c1cbdf32f9c1..4f2d3a9208af 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -19,6 +19,10 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing import { applyExecPolicyLayer } from "../infra/exec-policy.js"; import { mergeGatewayAgentCliPath } from "../infra/openclaw-cli-shim.js"; import { logWarn } from "../logger.js"; +import { + MEMORY_POSTBOX_RUN_ID_BINDING, + MEMORY_POSTBOX_TURN_CAPABILITY_BINDING, +} from "../plugins/memory-postbox-tool-binding.js"; import type { PluginHookChannelContext, PluginHookToolRequesterContext, @@ -181,6 +185,8 @@ type OpenClawCodingToolsOptions = { clientCaps?: string[]; /** Out-of-band plugin bindings attached by the run initiator. */ toolBindings?: Readonly>; + /** Host-owned bindings delivered only to their named plugin tool factory. */ + pluginToolBindings?: Readonly>>>; /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ conversationRecall?: ConversationRecallContext; /** Normalized conversation kind when the caller already has channel metadata. */ @@ -194,6 +200,8 @@ type OpenClawCodingToolsOptions = { nativeChannelId?: string; /** Opaque host-issued capability for current-turn channel message actions. */ messageActionTurnCapability?: string; + /** Opaque core-issued source-message capability for one postbox deposit. */ + memoryPostboxTurnCapability?: string; sandbox?: SandboxContext | null; sessionKey?: string; /** @@ -394,6 +402,21 @@ type OpenClawCodingToolsOptions = { scheduledToolPolicy?: ScheduledToolPolicyContext; }; +function resolvePluginToolBindings(options: OpenClawCodingToolsOptions | undefined) { + const turnCapability = options?.memoryPostboxTurnCapability?.trim(); + const runId = options?.runId?.trim(); + if (!turnCapability || !runId) { + return options?.pluginToolBindings; + } + return { + ...options?.pluginToolBindings, + "memory-core": { + [MEMORY_POSTBOX_TURN_CAPABILITY_BINDING]: turnCapability, + [MEMORY_POSTBOX_RUN_ID_BINDING]: runId, + }, + } satisfies Readonly>>>; +} + function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions): AnyAgentTool[] { const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const isMemoryFlushRun = options?.trigger === "memory"; @@ -772,6 +795,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, clientCaps: options?.clientCaps, toolBindings: options?.toolBindings, + pluginToolBindings: resolvePluginToolBindings(options), authProfileStore: options?.authProfileStore, }, resolvedConfig: options?.config, @@ -926,12 +950,11 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) toolsForMemoryFlush.push(tool); } } - const unavailableCoreToolReason = - isMemoryFlushRun - ? memoryIsolationCutover - ? "memory-triggered compaction runs expose only subject-scoped memory_remember" - : "memory-triggered compaction runs expose only read and append-only write" - : undefined; + const unavailableCoreToolReason = isMemoryFlushRun + ? memoryIsolationCutover + ? "memory-triggered compaction runs expose only subject-scoped memory_remember" + : "memory-triggered compaction runs expose only read and append-only write" + : undefined; const toolsForMessageProvider = filterToolsByMessageProvider( toolsForMemoryFlush, options?.toolPolicyMessageProvider ?? options?.messageProvider, @@ -994,7 +1017,9 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) ? authorizedTools.filter((tool) => AUTHORIZED_MEMORY_VIEW_TOOL_NAMES.has(tool.name)) : memoryIsolationCutover ? isMemoryFlushRun - ? authorizedTools.filter((tool) => AUTHORIZED_MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) + ? authorizedTools.filter((tool) => + AUTHORIZED_MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name), + ) : authorizedTools.filter((tool) => MEMORY_ISOLATION_READ_TOOL_NAMES.has(tool.name)) : authorizedTools; if ( diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts index 89282754bee0..73fe895c74ce 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts @@ -9,7 +9,10 @@ import { logCodeModeDiagnostic, } from "../../../logging/code-mode-diagnostic.js"; import { extractModelCompat } from "../../../plugins/provider-model-compat.js"; -import type { AuthorizedMemoryReadHost, AuthorizedMemoryWriteHost } from "../../../plugins/tool-types.js"; +import type { + AuthorizedMemoryReadHost, + AuthorizedMemoryWriteHost, +} from "../../../plugins/tool-types.js"; import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js"; @@ -306,6 +309,7 @@ export async function prepareEmbeddedAttemptToolBase(params: { messageThreadId: attempt.messageThreadId, nativeChannelId: attempt.chatId, messageActionTurnCapability: attempt.messageActionTurnCapability, + memoryPostboxTurnCapability: attempt.memoryPostboxTurnCapability, groupId: attempt.groupId, groupChannel: attempt.groupChannel, groupSpace: attempt.groupSpace, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 4ccd46fc5bab..b986a9d2c987 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -137,6 +137,8 @@ export type RunEmbeddedAgentParams = { memberRoleIds?: string[]; /** Opaque host-issued capability for current-turn channel message actions. */ messageActionTurnCapability?: string; + /** Opaque core-issued source-message capability for one postbox deposit. */ + memoryPostboxTurnCapability?: string; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; /** Whether workspaceDir points at the canonical agent workspace for bootstrap purposes. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 5a7bc73eee6f..17aa6d7f486d 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -286,6 +286,7 @@ export async function dispatchEmbeddedRunAttempt(input: { messageThreadId: params.messageThreadId, conversationToolPolicy: params.conversationToolPolicy, messageActionTurnCapability: params.messageActionTurnCapability, + memoryPostboxTurnCapability: params.memoryPostboxTurnCapability, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index 6a7f5ec14f1b..81a1d90a0491 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -60,6 +60,7 @@ export type OpenClawPluginToolOptions = { sandboxed?: boolean; allowGatewaySubagentBinding?: boolean; toolBindings?: Readonly>; + pluginToolBindings?: Readonly>>>; activeProjectKeys?: readonly string[]; }; @@ -137,6 +138,7 @@ export function resolveOpenClawPluginToolInputs(params: { sessionKey: options?.agentSessionKey, sessionId: options?.sessionId, toolBindings: options?.toolBindings, + pluginToolBindings: options?.pluginToolBindings, activeProjectKeys: options?.activeProjectKeys, conversationRecall: options?.conversationRecall, ...(memoryReadEnforced ? { memoryReadEnforced: true as const } : {}), diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 1d538b61019d..733329aa1a16 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -20,6 +20,11 @@ import { resolveMessageActionTurnCapabilityLifetime, revokeMessageActionTurnCapability, } from "../../gateway/message-action-turn-capability.js"; +import { + mintMemoryPostboxTurnCapability, + resolveMemoryPostboxTargetPrincipal, + revokeMemoryPostboxTurnCapability, +} from "../../gateway/memory-postbox-turn-capability.js"; import { logVerbose } from "../../globals.js"; import { isMarkdownCapableMessageChannel, @@ -180,6 +185,37 @@ export async function runEmbeddedFallbackCandidate(params: { ...resolveMessageActionTurnCapabilityLifetime(runBaseParams.timeoutMs), }) : undefined; + const memoryPostboxTargetPrincipal = + embeddedContext.agentId && messageActionCapabilitySessionKey && embeddedContext.sessionId + ? resolveMemoryPostboxTargetPrincipal({ + agentId: embeddedContext.agentId, + sessionKey: messageActionCapabilitySessionKey, + sessionId: embeddedContext.sessionId, + }) + : undefined; + const memoryPostboxTurnCapability = + isTrustedMessageActionTurnIngress(turn.sessionCtx.Provider) && + !turn.isHeartbeat && + embeddedContext.agentId && + messageActionCapabilitySessionKey && + embeddedContext.sessionId && + embeddedContext.messageProvider && + embeddedContext.currentChannelId && + embeddedContext.currentMessageId !== undefined && + senderContext.senderId && + memoryPostboxTargetPrincipal + ? mintMemoryPostboxTurnCapability({ + agentId: embeddedContext.agentId, + runId: params.runId, + sessionKey: messageActionCapabilitySessionKey, + sessionId: embeddedContext.sessionId, + sourceChannelRef: `${embeddedContext.messageProvider}:${embeddedContext.currentChannelId}`, + sourceMessageRef: String(embeddedContext.currentMessageId), + senderEvidenceRef: `${embeddedContext.messageProvider}:${senderContext.senderId}`, + targetPrincipalId: memoryPostboxTargetPrincipal, + ...resolveMessageActionTurnCapabilityLifetime(runBaseParams.timeoutMs), + }) + : undefined; let attemptCompactionCount = 0; const lifecycleBackstop = createAgentLifecycleTerminalBackstop({ runId: params.runId, @@ -212,6 +248,7 @@ export async function runEmbeddedFallbackCandidate(params: { preparedRunAdmission: params.preparedRunAdmission, ...embeddedContext, messageActionTurnCapability, + memoryPostboxTurnCapability, lifecycleGeneration: params.getLifecycleGeneration(), allowGatewaySubagentBinding: true, trigger: turn.isHeartbeat ? "heartbeat" : "user", @@ -453,5 +490,6 @@ export async function runEmbeddedFallbackCandidate(params: { } finally { params.onCompactionCount(attemptCompactionCount); revokeMessageActionTurnCapability(messageActionTurnCapability); + revokeMemoryPostboxTurnCapability(memoryPostboxTurnCapability); } } diff --git a/src/gateway/memory-postbox-turn-capability.test.ts b/src/gateway/memory-postbox-turn-capability.test.ts new file mode 100644 index 000000000000..971dd6b1ea7d --- /dev/null +++ b/src/gateway/memory-postbox-turn-capability.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const sessionContextMock = vi.hoisted(() => vi.fn()); + +vi.mock("../state/memory-session-subject.js", () => ({ + createCurrentMemorySessionContext: sessionContextMock, +})); + +import { + mintMemoryPostboxTurnCapability, + resetMemoryPostboxTurnCapabilitiesForTest, + resolveMemoryPostboxTurnCapability, + resolveMemoryPostboxTargetPrincipal, + revokeMemoryPostboxTurnCapability, +} from "./memory-postbox-turn-capability.js"; + +describe("memory postbox turn capability", () => { + afterEach(() => { + resetMemoryPostboxTurnCapabilitiesForTest(); + sessionContextMock.mockReset(); + }); + + it("binds trusted source evidence to one live agent run and session", () => { + const token = mintMemoryPostboxTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:group:team", + sessionId: "session-1", + sourceChannelRef: "telegram:team", + sourceMessageRef: "message-1", + senderEvidenceRef: "sender-evidence-1", + targetPrincipalId: "principal-alice", + nowMs: 100, + ttlMs: 1_000, + }); + + expect( + resolveMemoryPostboxTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:group:team", + sessionId: "session-1", + nowMs: 101, + }), + ).toEqual({ + sourceChannelRef: "telegram:team", + sourceMessageRef: "message-1", + senderEvidenceRef: "sender-evidence-1", + targetPrincipalId: "principal-alice", + }); + for (const mismatch of [ + { agentId: "other", runId: "run-1", sessionKey: "agent:main:group:team", sessionId: "session-1" }, + { agentId: "main", runId: "run-2", sessionKey: "agent:main:group:team", sessionId: "session-1" }, + { agentId: "main", runId: "run-1", sessionKey: "agent:main:group:other", sessionId: "session-1" }, + { agentId: "main", runId: "run-1", sessionKey: "agent:main:group:team", sessionId: "session-2" }, + ]) { + expect(resolveMemoryPostboxTurnCapability({ token, ...mismatch, nowMs: 101 })).toBeUndefined(); + } + expect( + resolveMemoryPostboxTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:group:team", + sessionId: "session-1", + nowMs: 1_100, + }), + ).toBeUndefined(); + }); + + it("cannot be redeemed after explicit terminal revocation", () => { + const token = mintMemoryPostboxTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:group:team", + sourceChannelRef: "telegram:team", + sourceMessageRef: "message-1", + senderEvidenceRef: "sender-evidence-1", + targetPrincipalId: "principal-alice", + }); + expect(revokeMemoryPostboxTurnCapability(token)).toBe(true); + expect( + resolveMemoryPostboxTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:group:team", + }), + ).toBeUndefined(); + }); + + it("selects a postbox target only from the current verified direct-user session", () => { + sessionContextMock.mockReturnValue({ + kind: "current", + context: { subject: { kind: "user" }, principalId: "principal-alice" }, + }); + + expect( + resolveMemoryPostboxTargetPrincipal({ + agentId: "main", + sessionKey: "agent:main:direct:alice", + sessionId: "session-1", + }), + ).toBe("principal-alice"); + expect(sessionContextMock).toHaveBeenCalledWith({ + sessionKey: "agent:main:direct:alice", + sessionId: "session-1", + options: { agentId: "main" }, + }); + + sessionContextMock.mockReturnValue({ + kind: "current", + context: { subject: { kind: "conversation" }, principalId: "conversation-team" }, + }); + expect( + resolveMemoryPostboxTargetPrincipal({ + agentId: "main", + sessionKey: "agent:main:group:team", + sessionId: "session-2", + }), + ).toBeUndefined(); + }); +}); diff --git a/src/gateway/memory-postbox-turn-capability.ts b/src/gateway/memory-postbox-turn-capability.ts new file mode 100644 index 000000000000..6f2459b09fc2 --- /dev/null +++ b/src/gateway/memory-postbox-turn-capability.ts @@ -0,0 +1,164 @@ +import { randomBytes } from "node:crypto"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { createCurrentMemorySessionContext } from "../state/memory-session-subject.js"; + +const DEFAULT_TTL_MS = 15 * 60_000; +const MAX_TTL_MS = 24 * 60 * 60_000; +const MAX_ACTIVE_CAPABILITIES = 4096; +const RUN_LIFETIME_EXPIRES_AT_MS = Number.MAX_SAFE_INTEGER; + +type MemoryPostboxTurnCapability = Readonly<{ + agentId: string; + runId: string; + sessionKey: string; + sessionId?: string; + sourceChannelRef: string; + sourceMessageRef: string; + senderEvidenceRef: string; + targetPrincipalId: string; + expiresAtMs: number; +}>; + +export type ResolvedMemoryPostboxSource = Readonly<{ + sourceChannelRef: string; + sourceMessageRef: string; + senderEvidenceRef: string; + /** The core-selected private subject whose quarantine receives this source. */ + targetPrincipalId: string; +}>; + +const capabilitiesByToken = new Map(); + +function resolveTtlMs(value: number | undefined): number { + if (!Number.isFinite(value) || value === undefined || value <= 0) { + return DEFAULT_TTL_MS; + } + return Math.min(Math.trunc(value), MAX_TTL_MS); +} + +function required(value: string | undefined, label: string): string { + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error(`memory postbox turn capability requires ${label}`); + } + return normalized; +} + +function sweepExpiredMemoryPostboxTurnCapabilities(nowMs: number = Date.now()): void { + for (const [token, capability] of capabilitiesByToken) { + if (nowMs >= capability.expiresAtMs) { + capabilitiesByToken.delete(token); + } + } +} + +/** + * Minted only from an admitted inbound user turn. The opaque token is later scoped to the selected + * memory plugin, which redeems current channel/sender evidence without accepting it from tool args. + */ +export function mintMemoryPostboxTurnCapability(params: { + agentId: string; + runId: string; + sessionKey: string; + sessionId?: string; + sourceChannelRef: string; + sourceMessageRef: string; + senderEvidenceRef: string; + /** Resolved only by core from the current verified user session subject. */ + targetPrincipalId: string; + expiresWithRun?: boolean; + ttlMs?: number; + nowMs?: number; +}): string { + const agentId = normalizeAgentId(params.agentId); + const runId = required(params.runId, "run identity"); + const sessionKey = required(params.sessionKey, "session identity"); + const nowMs = params.nowMs ?? Date.now(); + sweepExpiredMemoryPostboxTurnCapabilities(nowMs); + pruneMapToMaxSize(capabilitiesByToken, MAX_ACTIVE_CAPABILITIES - 1); + const token = randomBytes(32).toString("base64url"); + capabilitiesByToken.set( + token, + Object.freeze({ + agentId, + runId, + sessionKey, + sessionId: normalizeOptionalString(params.sessionId), + sourceChannelRef: required(params.sourceChannelRef, "source channel evidence"), + sourceMessageRef: required(params.sourceMessageRef, "source message evidence"), + senderEvidenceRef: required(params.senderEvidenceRef, "sender evidence"), + targetPrincipalId: required(params.targetPrincipalId, "target principal"), + expiresAtMs: params.expiresWithRun + ? RUN_LIFETIME_EXPIRES_AT_MS + : nowMs + resolveTtlMs(params.ttlMs), + }), + ); + return token; +} + +/** Rejects forged, stale, cross-agent, cross-run, and cross-session tokens without disclosure. */ +export function resolveMemoryPostboxTurnCapability(params: { + token?: string; + agentId: string; + runId?: string; + sessionKey: string; + sessionId?: string; + nowMs?: number; +}): ResolvedMemoryPostboxSource | undefined { + const token = normalizeOptionalString(params.token); + if (!token) { + return undefined; + } + const capability = capabilitiesByToken.get(token); + if (!capability) { + return undefined; + } + const nowMs = params.nowMs ?? Date.now(); + if ( + nowMs >= capability.expiresAtMs || + capability.agentId !== normalizeAgentId(params.agentId) || + capability.runId !== normalizeOptionalString(params.runId) || + capability.sessionKey !== normalizeOptionalString(params.sessionKey) || + (capability.sessionId && capability.sessionId !== normalizeOptionalString(params.sessionId)) + ) { + if (nowMs >= capability.expiresAtMs) { + capabilitiesByToken.delete(token); + } + return undefined; + } + return Object.freeze({ + sourceChannelRef: capability.sourceChannelRef, + sourceMessageRef: capability.sourceMessageRef, + senderEvidenceRef: capability.senderEvidenceRef, + targetPrincipalId: capability.targetPrincipalId, + }); +} + +/** + * A postbox source can target only the current verified user's quarantine. Group and autonomous + * sessions intentionally have no fallback target: a sender id or model argument must not pick one. + */ +export function resolveMemoryPostboxTargetPrincipal(params: { + agentId: string; + sessionKey: string; + sessionId: string; +}): string | undefined { + const session = createCurrentMemorySessionContext({ + sessionKey: params.sessionKey, + sessionId: params.sessionId, + options: { agentId: params.agentId }, + }); + return session.kind === "current" && session.context.subject.kind === "user" + ? session.context.principalId + : undefined; +} + +export function revokeMemoryPostboxTurnCapability(token: string | undefined): boolean { + return Boolean(token && capabilitiesByToken.delete(token)); +} + +export function resetMemoryPostboxTurnCapabilitiesForTest(): void { + capabilitiesByToken.clear(); +} diff --git a/src/plugin-sdk/memory-postbox-runtime.ts b/src/plugin-sdk/memory-postbox-runtime.ts new file mode 100644 index 000000000000..fbc5768a45c3 --- /dev/null +++ b/src/plugin-sdk/memory-postbox-runtime.ts @@ -0,0 +1,9 @@ +/** Core-issued source-message capability for selected memory plugins. */ +export { + MEMORY_POSTBOX_RUN_ID_BINDING, + MEMORY_POSTBOX_TURN_CAPABILITY_BINDING, +} from "../plugins/memory-postbox-tool-binding.js"; +export { + resolveMemoryPostboxTurnCapability, + type ResolvedMemoryPostboxSource, +} from "../gateway/memory-postbox-turn-capability.js"; diff --git a/src/plugins/memory-postbox-tool-binding.ts b/src/plugins/memory-postbox-tool-binding.ts new file mode 100644 index 000000000000..ce442c2eff8e --- /dev/null +++ b/src/plugins/memory-postbox-tool-binding.ts @@ -0,0 +1,3 @@ +/** Scoped host-binding keys; only the selected memory plugin receives these values. */ +export const MEMORY_POSTBOX_TURN_CAPABILITY_BINDING = "memoryPostboxTurnCapability"; +export const MEMORY_POSTBOX_RUN_ID_BINDING = "memoryPostboxRunId"; diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index 463d98411569..2502ca9abb76 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -81,6 +81,8 @@ export type OpenClawPluginToolContext = { sessionId?: string; /** Out-of-band plugin-owned bindings attached by the current run initiator. */ toolBindings?: Readonly>; + /** Host-owned bindings scoped to one selected plugin before its factory runs. */ + pluginToolBindings?: Readonly>>>; /** Host-prepared repository identities for project-aware tool behavior. */ activeProjectKeys?: readonly string[]; /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 2a77c1922156..70a85bfcdd63 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -2020,6 +2020,49 @@ describe("resolvePluginTools optional tools", () => { expect(registry.diagnostics).toHaveLength(0); }); + it("delivers a host binding only to its selected plugin factory", () => { + const memoryFactory = vi.fn(() => makeTool("memory_postbox_deposit")); + const otherFactory = vi.fn(() => makeTool("other_tool")); + setRegistry([ + { + pluginId: "memory-core", + optional: false, + source: "/tmp/memory-core.js", + names: ["memory_postbox_deposit"], + factory: memoryFactory, + }, + { + pluginId: "other-plugin", + optional: false, + source: "/tmp/other-plugin.js", + names: ["other_tool"], + factory: otherFactory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + toolBindings: { ordinaryBinding: "ordinary" }, + pluginToolBindings: { + "memory-core": { memoryPostboxTurnCapability: "opaque" }, + }, + }, + }), + ); + + expectResolvedToolNames(tools, ["memory_postbox_deposit", "other_tool"]); + expect(memoryFactory).toHaveBeenCalledWith( + expect.objectContaining({ toolBindings: { memoryPostboxTurnCapability: "opaque" } }), + ); + expect(memoryFactory.mock.calls[0]?.[0]).not.toHaveProperty("pluginToolBindings"); + expect(otherFactory).toHaveBeenCalledWith( + expect.objectContaining({ toolBindings: { ordinaryBinding: "ordinary" } }), + ); + expect(otherFactory.mock.calls[0]?.[0]).not.toHaveProperty("pluginToolBindings"); + }); + it("isolates tools with malformed required client capabilities", () => { const registry = setRegistry([ { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 7d8d2c739101..92957108dec5 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -250,8 +250,14 @@ function resolvePluginToolFactory( pluginRegistry: PluginRegistry | undefined, ctx: OpenClawPluginToolContext, ) { + const { pluginToolBindings, ...context } = ctx; + const scopedBindings = pluginToolBindings?.[entry.pluginId]; return runWithPluginToolScope(entry, pluginRegistry, () => - wrapPluginToolFactoryResult(entry, pluginRegistry, entry.factory(ctx)), + wrapPluginToolFactoryResult( + entry, + pluginRegistry, + entry.factory(scopedBindings ? { ...context, toolBindings: scopedBindings } : context), + ), ); } diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index a93661b04a11..76aec593f577 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -1417,10 +1417,13 @@ BEGIN END; CREATE TABLE IF NOT EXISTS memory_postbox_settings ( - agent_id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL, + target_store_id TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('off', 'review-required')), updated_by_principal_id TEXT NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + PRIMARY KEY (agent_id, target_store_id), + FOREIGN KEY (target_store_id) REFERENCES memory_stores(store_id) ON DELETE RESTRICT ) STRICT; CREATE TABLE IF NOT EXISTS memory_postbox_source_handles (