mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
feat(memory): bind postbox deposits to verified turns
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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<string, (ctx: never) => 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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"kind": "memory",
|
||||
"contracts": {
|
||||
"tools": ["intent", "memory_get", "memory_search"]
|
||||
"tools": ["intent", "memory_get", "memory_postbox_deposit", "memory_search"]
|
||||
},
|
||||
"toolMetadata": {
|
||||
"intent": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof getNodeSqliteKysely<ScopedMemoryDatabase>>[0];
|
||||
agentId: string;
|
||||
targetPrincipalId: string;
|
||||
}) {
|
||||
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(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<typeof getNodeSqliteKysely<ScopedMemoryDatabase>>[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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
/** Host-owned bindings delivered only to their named plugin tool factory. */
|
||||
pluginToolBindings?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
/** 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<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type OpenClawPluginToolOptions = {
|
||||
sandboxed?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
pluginToolBindings?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, MemoryPostboxTurnCapability>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -81,6 +81,8 @@ export type OpenClawPluginToolContext = {
|
||||
sessionId?: string;
|
||||
/** Out-of-band plugin-owned bindings attached by the current run initiator. */
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
/** Host-owned bindings scoped to one selected plugin before its factory runs. */
|
||||
pluginToolBindings?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
/** Host-prepared repository identities for project-aware tool behavior. */
|
||||
activeProjectKeys?: readonly string[];
|
||||
/** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user