feat(memory): wire authorization shadow interfaces

This commit is contained in:
Galin Iliev
2026-08-08 22:08:54 -07:00
parent b98b67d6ec
commit 4b21da753e
9 changed files with 1648 additions and 8 deletions
+14 -2
View File
@@ -6,17 +6,21 @@ import {
} from "./project-memory-bootstrap.js";
const runtimeMocks = vi.hoisted(() => ({
getSelectedRuntime: vi.fn(),
getManager: vi.fn(),
listCurated: vi.fn(),
search: vi.fn(),
}));
vi.mock("../plugins/memory-state.js", () => ({
getMemoryRuntime: () => ({ getMemorySearchManager: runtimeMocks.getManager }),
vi.mock("../plugins/memory-runtime.js", () => ({
getSelectedMemoryRuntime: runtimeMocks.getSelectedRuntime,
}));
describe("project memory bootstrap", () => {
beforeEach(() => {
runtimeMocks.getSelectedRuntime
.mockReset()
.mockReturnValue({ getMemorySearchManager: runtimeMocks.getManager });
runtimeMocks.getManager.mockReset();
runtimeMocks.listCurated.mockReset();
runtimeMocks.search.mockReset();
@@ -97,10 +101,18 @@ describe("project memory bootstrap", () => {
it("keeps sessions without an active repository unchanged", async () => {
await expect(prepareEntries(entries, [])).resolves.toEqual([]);
expect(runtimeMocks.getSelectedRuntime).not.toHaveBeenCalled();
expect(runtimeMocks.getManager).not.toHaveBeenCalled();
expect(buildProjectMemoryWriteInstruction(undefined)).toBe("");
});
it("acquires project bootstrap memory through the selected-runtime seam", async () => {
await prepareEntries(entries);
expect(runtimeMocks.getSelectedRuntime).toHaveBeenCalledOnce();
expect(runtimeMocks.getManager).toHaveBeenCalledOnce();
});
it("filters tagged raw entries fail-closed with the all-keys rule", () => {
const contextFiles = [
{
+2 -2
View File
@@ -6,7 +6,7 @@ import {
} from "../../packages/memory-host-sdk/src/engine-storage.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { MemorySearchResult } from "../memory-host-sdk/host/types.js";
import { getMemoryRuntime } from "../plugins/memory-state.js";
import { getSelectedMemoryRuntime } from "../plugins/memory-runtime.js";
import type { EmbeddedContextFile } from "./embedded-agent-helpers.js";
const PROJECT_MEMORY_BOOTSTRAP_MAX_CHARS = 2_000;
@@ -123,7 +123,7 @@ export async function prepareProjectMemoryBootstrap(params: {
if (params.activeProjectKeys.length === 0) {
return [];
}
const runtime = getMemoryRuntime();
const runtime = getSelectedMemoryRuntime();
if (!runtime) {
return [];
}
@@ -0,0 +1,496 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { listGitTrackedFiles } from "../test-utils/repo-files.js";
import {
MEMORY_AUTHORIZATION_PATH_DISPOSITIONS,
MEMORY_AUTHORIZATION_PATH_INVENTORY,
type MemoryAuthorizationPathInventoryEntry,
} from "./memory-authorization-path-inventory.js";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const REQUIRED_PHASE_0_PATH_IDS = [
"selected-runtime-manager-acquisition",
"selected-runtime-backend-resolution",
"selected-runtime-startup-warmup-and-sync",
"bootstrap-memory-and-user-files",
"startup-recent-memory-context",
"memory-search-tool",
"memory-get-tool",
"session-transcript-search",
"active-memory-trigger-recall",
"memory-wiki-prompt-supplement-registration",
"memory-wiki-prompt-preparation-registration",
"memory-wiki-corpus-supplement-registration",
"memory-wiki-agent-query-read",
"memory-wiki-agent-mutation",
"memory-wiki-operator-query-read",
"memory-wiki-operator-mutation",
"memory-wiki-bridge-artifact-read",
"memory-wiki-bridge-artifact-import",
"memory-wiki-status",
"lancedb-tool-recall",
"lancedb-tool-store",
"lancedb-tool-forget",
"lancedb-auto-recall",
"lancedb-auto-capture",
"lancedb-cli-read",
"memory-prompt-supplements",
"memory-prompt-preparations",
"memory-corpus-supplements",
"talk-fast-context",
"project-memory-bootstrap",
"memory-status-command",
"memory-cli-search-and-get",
"memory-cli-sync-and-reindex",
"memory-doctor-inspection-and-repair",
"gateway-memory-search",
"memory-import",
"memory-migration-import",
"memory-export",
"memory-public-artifact-provider-list",
"generic-file-read",
"generic-file-write",
"generic-file-edit",
"generic-file-apply-patch",
"sandbox-workspace-mounts",
"unsandboxed-exec",
"post-compaction-session-memory-sync",
"memory-flush",
"transcript-event-write",
"transcript-history-and-replay",
"compaction-summary",
"compaction-checkpoint",
"compaction-checkpoint-operator-branch-and-restore",
"dreaming-source-recall",
"dreaming-derived-artifacts",
"profile-and-short-term-promotion",
"child-agent-delegation",
"child-agent-completion-handoff",
"cron-triggered-run",
"heartbeat-triggered-run",
"webhook-triggered-run",
"system-triggered-run",
"final-reply-delivery",
"message-tool-delivery",
"session-send-delivery",
"plugin-and-mcp-outbound-actions",
] as const;
const MEMORY_MANAGER_CALL_NAMES = new Set([
"getActiveMemorySearchManager",
"getMemorySearchManager",
]);
const SUPPLEMENTAL_PATH_DIRECTIONS = {
"memory-wiki-prompt-supplement-registration": "egress",
"memory-wiki-prompt-preparation-registration": "egress",
"memory-wiki-corpus-supplement-registration": "egress",
"memory-wiki-agent-query-read": "egress",
"memory-wiki-agent-mutation": "ingress",
"memory-wiki-operator-query-read": "egress",
"memory-wiki-operator-mutation": "ingress",
"memory-wiki-bridge-artifact-read": "egress",
"memory-wiki-bridge-artifact-import": "ingress",
"lancedb-tool-recall": "egress",
"lancedb-tool-store": "ingress",
"lancedb-tool-forget": "ingress",
"lancedb-auto-recall": "egress",
"lancedb-auto-capture": "ingress",
"lancedb-cli-read": "egress",
} as const;
const MEMORY_MIGRATION_IMPORT_ROUTE_SURFACES = [
"src/cli/program/register.migrate.ts",
"src/commands/migrate.ts",
"src/commands/migrate/memory-import.ts",
"src/commands/migrate/apply.ts",
"extensions/migrate-claude/provider.ts",
"extensions/migrate-claude/plan.ts",
"extensions/migrate-claude/memory.ts",
"extensions/migrate-claude/apply.ts",
"extensions/migrate-hermes/provider.ts",
"extensions/migrate-hermes/plan.ts",
"extensions/migrate-hermes/memory.ts",
"extensions/migrate-hermes/apply.ts",
"extensions/codex/src/migration/provider.ts",
"extensions/codex/src/migration/plan.ts",
"extensions/codex/src/migration/apply.ts",
] as const;
const MEMORY_MIGRATION_IMPORT_ROOTS = [
"src/cli/program/register.migrate.ts",
"src/commands/migrate.ts",
"src/commands/migrate",
"extensions/migrate-claude",
"extensions/migrate-hermes",
"extensions/codex/src/migration",
] as const;
function isProductionTypeScript(file: string): boolean {
return (
file.endsWith(".ts") &&
!file.endsWith(".d.ts") &&
!file.includes(".test.") &&
!file.includes(".spec.") &&
!file.includes(".test-") &&
!/(^|\/)test[-.]/u.test(file) &&
!/(^|\/)tests?\//u.test(file)
);
}
function listContextFreeMemoryManagerCalls(file: string, sourceText: string): string[] {
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true);
const callableBindings = new Set(MEMORY_MANAGER_CALL_NAMES);
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement)) {
continue;
}
const bindings = statement.importClause?.namedBindings;
if (!bindings || statement.importClause?.isTypeOnly || ts.isNamespaceImport(bindings)) {
continue;
}
for (const element of bindings.elements) {
const imported = element.propertyName?.text ?? element.name.text;
if (!element.isTypeOnly && MEMORY_MANAGER_CALL_NAMES.has(imported)) {
callableBindings.add(element.name.text);
}
}
}
const calls: string[] = [];
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
if (
(ts.isIdentifier(expression) && callableBindings.has(expression.text)) ||
(ts.isPropertyAccessExpression(expression) &&
MEMORY_MANAGER_CALL_NAMES.has(expression.name.text)) ||
(ts.isElementAccessExpression(expression) &&
expression.argumentExpression !== undefined &&
ts.isStringLiteral(expression.argumentExpression) &&
MEMORY_MANAGER_CALL_NAMES.has(expression.argumentExpression.text))
) {
calls.push(expression.getText(source));
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(source, visit);
return calls;
}
function listImportedCallBindings(source: ts.SourceFile, importedName: string): Set<string> {
const bindings = new Set([importedName]);
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement)) {
continue;
}
const namedBindings = statement.importClause?.namedBindings;
if (
!namedBindings ||
statement.importClause?.isTypeOnly ||
ts.isNamespaceImport(namedBindings)
) {
continue;
}
for (const element of namedBindings.elements) {
const imported = element.propertyName?.text ?? element.name.text;
if (!element.isTypeOnly && imported === importedName) {
bindings.add(element.name.text);
}
}
}
return bindings;
}
function isNamedCall(
expression: ts.LeftHandSideExpression,
bindings: ReadonlySet<string>,
): boolean {
return (
(ts.isIdentifier(expression) && bindings.has(expression.text)) ||
(ts.isPropertyAccessExpression(expression) && bindings.has(expression.name.text))
);
}
function isMigrationProviderApplyCall(expression: ts.LeftHandSideExpression): boolean {
if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== "apply") {
return false;
}
const receiver = expression.expression;
return (
(ts.isIdentifier(receiver) && receiver.text === "provider") ||
(ts.isPropertyAccessExpression(receiver) && receiver.name.text === "provider")
);
}
function containsMemoryStringLiteral(node: ts.Node): boolean {
let found = false;
const visit = (child: ts.Node) => {
if (ts.isStringLiteral(child) && child.text === "memory") {
found = true;
return;
}
ts.forEachChild(child, visit);
};
visit(node);
return found;
}
function isMemoryKindPropertyOfMigrationItem(
node: ts.PropertyAssignment,
createItemBindings: ReadonlySet<string>,
): boolean {
if (
!ts.isIdentifier(node.name) ||
node.name.text !== "kind" ||
!containsMemoryStringLiteral(node.initializer)
) {
return false;
}
const object = node.parent;
const call = object.parent;
return ts.isObjectLiteralExpression(object) && ts.isCallExpression(call)
? isNamedCall(call.expression, createItemBindings)
: false;
}
function listMemoryMigrationIngressMarkers(file: string, sourceText: string): string[] {
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true);
const createItemBindings = listImportedCallBindings(source, "createMigrationItem");
const copyMemoryBindings = listImportedCallBindings(source, "copyMemoryMigrationFileItem");
const defaultCommandBindings = listImportedCallBindings(source, "migrateDefaultCommand");
const applyCommandBindings = listImportedCallBindings(source, "migrateApplyCommand");
const applyBindings = listImportedCallBindings(source, "runMigrationApply");
const markers: string[] = [];
const visit = (node: ts.Node) => {
if (
ts.isPropertyAssignment(node) &&
isMemoryKindPropertyOfMigrationItem(node, createItemBindings)
) {
markers.push(node.getText(source));
}
if (ts.isCallExpression(node)) {
if (isNamedCall(node.expression, copyMemoryBindings)) {
markers.push(node.getText(source));
}
if (isNamedCall(node.expression, defaultCommandBindings)) {
markers.push("migration-default-command-dispatch");
}
if (isNamedCall(node.expression, applyCommandBindings)) {
markers.push("migration-apply-command-dispatch");
}
if (isNamedCall(node.expression, applyBindings)) {
// Generic migration application admits provider plans containing memory items, so every
// caller is an ingress route until an owning Phase records its authorization boundary.
markers.push("migration-plan-apply");
}
if (isMigrationProviderApplyCall(node.expression)) {
markers.push("migration-provider-apply");
}
}
ts.forEachChild(node, visit);
};
visit(source);
return markers;
}
describe("memory authorization path inventory", () => {
const inventory: readonly MemoryAuthorizationPathInventoryEntry[] =
MEMORY_AUTHORIZATION_PATH_INVENTORY;
it("records every required ingress and egress path with one owner and disposition", () => {
const ids = inventory.map((entry) => entry.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(expect.arrayContaining([...REQUIRED_PHASE_0_PATH_IDS]));
expect(inventory.length).toBeGreaterThanOrEqual(55);
for (const item of inventory) {
expect(item.owner.length).toBeGreaterThan(0);
expect(MEMORY_AUTHORIZATION_PATH_DISPOSITIONS).toContain(item.disposition);
expect(item.surfaces.length).toBeGreaterThan(0);
}
});
it("keeps Phase 0 shadow-only and explicitly fails enforced bypasses closed", () => {
expect(inventory.filter((item) => item.disposition === "authorized")).toEqual([]);
expect(inventory.filter((item) => item.disposition === "operator-only-authenticated")).toEqual(
[],
);
expect(inventory.some((item) => item.disposition === "legacy-only")).toBe(true);
expect(inventory.some((item) => item.disposition === "blocked-in-enforced-mode")).toBe(true);
});
it("keeps supplemental reads and mutations as distinct enforced-mode paths", () => {
const entriesById = new Map(inventory.map((item) => [item.id, item]));
for (const [id, direction] of Object.entries(SUPPLEMENTAL_PATH_DIRECTIONS)) {
expect(entriesById.get(id)).toMatchObject({
direction,
disposition: "blocked-in-enforced-mode",
});
}
});
it("keeps post-compaction session sync separate from memory-flush derivation", () => {
const entriesById = new Map(inventory.map((item) => [item.id, item]));
expect(entriesById.get("post-compaction-session-memory-sync")).toMatchObject({
direction: "ingress",
owner: "core-agent-runtime",
disposition: "blocked-in-enforced-mode",
surfaces: expect.arrayContaining([
"src/agents/embedded-agent-runner/compaction-hooks.ts",
"src/agents/embedded-agent-runner/compaction-session-execution.ts",
"src/agents/embedded-agent-runner/compact.queued.ts",
"src/agents/embedded-agent-runner/run/timeout-context-recovery.ts",
]),
});
expect(entriesById.get("memory-flush")).toMatchObject({
direction: "derive",
});
});
it("records the complete operator migration memory-import route", () => {
const migrationImport = inventory.find((item) => item.id === "memory-migration-import");
expect(migrationImport).toMatchObject({
direction: "ingress",
owner: "operator-memory-host",
disposition: "blocked-in-enforced-mode",
surfaces: expect.arrayContaining([...MEMORY_MIGRATION_IMPORT_ROUTE_SURFACES]),
});
});
it("names only existing production surfaces", () => {
const missing = inventory.flatMap((item) =>
item.surfaces
.filter((surface) => !fs.existsSync(path.join(REPO_ROOT, surface)))
.map((surface) => `${item.id}:${surface}`),
);
expect(missing).toEqual([]);
});
it("finds direct and aliased context-free manager acquisition calls", () => {
expect(
listContextFreeMemoryManagerCalls(
"fixture.ts",
`
import { getActiveMemorySearchManager as active } from "./memory-runtime.js";
import { getMemorySearchManager as manager } from "./manager.js";
import * as runtime from "./memory-runtime.js";
import type { getMemorySearchManager as TypeOnly } from "./manager.js";
interface Runtime { getMemorySearchManager(params: unknown): Promise<unknown>; }
async function acquire(value: Runtime) {
await active({}); await manager({}); await runtime.getActiveMemorySearchManager({});
await value.getMemorySearchManager({}); await value["getMemorySearchManager"]({});
}
`,
),
).toEqual([
"active",
"manager",
"runtime.getActiveMemorySearchManager",
"value.getMemorySearchManager",
'value["getMemorySearchManager"]',
]);
});
it("finds the generic migration provider apply consumer without matching unrelated apply calls", () => {
expect(
listMemoryMigrationIngressMarkers(
"fixture.ts",
`
async function apply(params: { provider: { apply: Function } }, ctx: unknown) {
await params.provider.apply(ctx, {});
await other.apply(ctx, {});
}
`,
),
).toEqual(["migration-provider-apply"]);
});
it("finds generic migration plan application consumers that can apply memory items", () => {
expect(
listMemoryMigrationIngressMarkers(
"fixture.ts",
`
import { runMigrationApply as apply } from "./apply.js";
async function migrate() {
await apply({});
await unrelated({});
}
`,
),
).toEqual(["migration-plan-apply"]);
});
it("recognizes the generic CLI migration apply ingress", () => {
const command = "src/cli/program/register.migrate.ts";
const source = fs.readFileSync(path.join(REPO_ROOT, command), "utf8");
expect(listMemoryMigrationIngressMarkers(command, source)).toEqual(
expect.arrayContaining([
"migration-default-command-dispatch",
"migration-apply-command-dispatch",
]),
);
});
it("does not treat test-only source helpers as production manager paths", () => {
expect(
isProductionTypeScript("extensions/memory-core/src/memory/test-manager-helpers.ts"),
).toBe(false);
expect(isProductionTypeScript("extensions/memory-core/src/memory/search-manager.ts")).toBe(
true,
);
});
it("does not allow an unrecorded production context-free manager acquisition", () => {
const tracked = listGitTrackedFiles({
repoRoot: REPO_ROOT,
pathspecs: ["src", "extensions", "packages"],
});
if (!tracked) {
throw new Error("could not list tracked files for the authorization-path inventory");
}
const inventoried = new Set(inventory.flatMap((item) => item.surfaces));
const missing = tracked
.filter(isProductionTypeScript)
.filter(
(file) =>
listContextFreeMemoryManagerCalls(
file,
fs.readFileSync(path.join(REPO_ROOT, file), "utf8"),
).length > 0,
)
.filter((file) => !inventoried.has(file));
expect(missing).toEqual([]);
});
it("does not allow an unrecorded production migration memory-import producer or consumer", () => {
const tracked = listGitTrackedFiles({
repoRoot: REPO_ROOT,
pathspecs: [...MEMORY_MIGRATION_IMPORT_ROOTS],
});
if (!tracked) {
throw new Error(
"could not list tracked files for the migration authorization-path inventory",
);
}
const inventoried = new Set(inventory.flatMap((item) => item.surfaces));
const missing = tracked
.filter(isProductionTypeScript)
.filter(
(file) =>
listMemoryMigrationIngressMarkers(
file,
fs.readFileSync(path.join(REPO_ROOT, file), "utf8"),
).length > 0,
)
.filter((file) => !inventoried.has(file));
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,575 @@
/** Phase-0 inventory of every known path that can ingest, expose, or derive memory. */
export const MEMORY_AUTHORIZATION_PATH_DISPOSITIONS = [
"authorized",
"blocked-in-enforced-mode",
"legacy-only",
"operator-only-authenticated",
] as const;
export type MemoryAuthorizationPathDisposition =
(typeof MEMORY_AUTHORIZATION_PATH_DISPOSITIONS)[number];
export type MemoryAuthorizationPathDirection = "control" | "ingress" | "egress" | "derive";
export type MemoryAuthorizationPathOwner =
| "core-access-host"
| "core-agent-runtime"
| "core-session-runtime"
| "core-tool-runtime"
| "operator-memory-host"
| "selected-memory-plugin"
| "supplemental-memory-plugin"
| "transport-egress-host"
| "autonomous-run-host";
export type MemoryAuthorizationPathInventoryEntry = Readonly<{
id: string;
direction: MemoryAuthorizationPathDirection;
owner: MemoryAuthorizationPathOwner;
disposition: MemoryAuthorizationPathDisposition;
surfaces: readonly [string, ...string[]];
}>;
function entry(
id: string,
direction: MemoryAuthorizationPathDirection,
owner: MemoryAuthorizationPathOwner,
disposition: MemoryAuthorizationPathDisposition,
...surfaces: [string, ...string[]]
): MemoryAuthorizationPathInventoryEntry {
return Object.freeze({ id, direction, owner, disposition, surfaces: Object.freeze(surfaces) });
}
/**
* `authorized` is intentionally absent in Phase 0: the rollout is shadow-only. Every path keeps
* its current legacy behavior until its owning phase converts it, and enforced mode then blocks
* the explicitly listed bypasses rather than silently falling through to context-free access.
*/
export const MEMORY_AUTHORIZATION_PATH_INVENTORY = Object.freeze([
entry(
"selected-runtime-manager-acquisition",
"control",
"core-access-host",
"legacy-only",
"src/plugins/memory-runtime.ts",
"src/plugins/memory-state.ts",
"src/plugin-sdk/memory-host-search.ts",
"extensions/memory-core/index.ts",
"extensions/memory-core/src/runtime-provider.ts",
"extensions/memory-core/src/memory/search-manager.ts",
),
entry(
"selected-runtime-backend-resolution",
"control",
"core-access-host",
"legacy-only",
"src/plugins/memory-runtime.ts",
),
entry(
"selected-runtime-startup-warmup-and-sync",
"control",
"selected-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-core/index.ts",
"src/gateway/server-startup-memory.ts",
),
entry(
"bootstrap-memory-and-user-files",
"egress",
"core-agent-runtime",
"legacy-only",
"src/agents/bootstrap-files.ts",
"src/agents/workspace-bootstrap-read.ts",
),
entry(
"startup-recent-memory-context",
"egress",
"core-agent-runtime",
"legacy-only",
"src/auto-reply/reply/startup-context.ts",
),
entry(
"memory-search-tool",
"egress",
"selected-memory-plugin",
"legacy-only",
"extensions/memory-core/src/tools.ts",
"extensions/memory-core/src/tools.shared.ts",
),
entry(
"memory-get-tool",
"egress",
"selected-memory-plugin",
"legacy-only",
"extensions/memory-core/src/tools.ts",
),
entry(
"session-transcript-search",
"egress",
"selected-memory-plugin",
"legacy-only",
"extensions/memory-core/src/session-search-visibility.ts",
"extensions/memory-core/src/tools.ts",
),
entry(
"active-memory-trigger-recall",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/active-memory/trigger-recall.ts",
),
entry(
"active-memory-session-recall",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/active-memory/recall.ts",
"extensions/active-memory/recall-run.ts",
),
entry(
"memory-wiki-prompt-supplement-registration",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/index.ts",
"extensions/memory-wiki/src/prompt-section.ts",
"src/plugins/memory-state.ts",
),
entry(
"memory-wiki-prompt-preparation-registration",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/index.ts",
"extensions/memory-wiki/src/prompt-section.ts",
"src/plugins/memory-state.ts",
),
entry(
"memory-wiki-corpus-supplement-registration",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/index.ts",
"extensions/memory-wiki/src/corpus-supplement.ts",
"extensions/memory-core/src/tools.shared.ts",
),
entry(
"memory-wiki-agent-query-read",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-wiki/index.ts",
"extensions/memory-wiki/src/tool.ts",
"extensions/memory-wiki/src/query.ts",
),
entry(
"memory-wiki-agent-mutation",
"ingress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-wiki/index.ts",
"extensions/memory-wiki/src/tool.ts",
),
entry(
"memory-wiki-operator-query-read",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/src/gateway.ts",
"extensions/memory-wiki/src/cli.ts",
"extensions/memory-wiki/src/query.ts",
),
entry(
"memory-wiki-operator-mutation",
"ingress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/src/gateway.ts",
"extensions/memory-wiki/src/cli.ts",
"extensions/memory-wiki/src/ingest.ts",
),
entry(
"memory-wiki-bridge-artifact-read",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-wiki/src/bridge.ts",
),
entry(
"memory-wiki-bridge-artifact-import",
"ingress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-wiki/src/bridge.ts",
"extensions/memory-wiki/src/source-page-shared.ts",
),
entry(
"memory-wiki-status",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-wiki/src/status.ts",
),
entry(
"lancedb-tool-recall",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
),
entry(
"lancedb-tool-store",
"ingress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
),
entry(
"lancedb-tool-forget",
"ingress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
),
entry(
"lancedb-auto-recall",
"egress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
),
entry(
"lancedb-auto-capture",
"ingress",
"supplemental-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
),
entry(
"lancedb-cli-read",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-lancedb/index.ts",
"extensions/memory-lancedb/memory-cli.ts",
),
entry(
"memory-prompt-supplements",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"src/plugins/memory-state.ts",
),
entry(
"memory-prompt-preparations",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"src/plugins/memory-state.ts",
),
entry(
"memory-corpus-supplements",
"egress",
"core-access-host",
"blocked-in-enforced-mode",
"src/plugins/memory-state.ts",
"extensions/memory-core/src/tools.shared.ts",
),
entry(
"talk-fast-context",
"egress",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/talk/fast-context-runtime.ts",
),
entry(
"project-memory-bootstrap",
"egress",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/project-memory-bootstrap.ts",
),
entry(
"memory-status-command",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-core/src/cli-status.runtime.ts",
"src/commands/status.scan-memory.ts",
"src/commands/status.scan.deps.runtime.ts",
"src/commands/status.scan.shared.ts",
),
entry(
"memory-cli-search-and-get",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-core/src/cli-index-search.runtime.ts",
"extensions/memory-core/src/cli-runtime-common.ts",
),
entry(
"memory-cli-sync-and-reindex",
"control",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-core/src/cli-index-search.runtime.ts",
),
entry(
"memory-doctor-inspection-and-repair",
"control",
"operator-memory-host",
"blocked-in-enforced-mode",
"src/commands/doctor-memory-search.ts",
"src/gateway/server-methods/doctor.ts",
),
entry(
"gateway-memory-search",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"src/gateway/server-methods/memory-search.ts",
),
entry(
"memory-import",
"ingress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-core/src/cli-rem.runtime.ts",
),
entry(
"memory-migration-import",
"ingress",
"operator-memory-host",
"blocked-in-enforced-mode",
"src/cli/program/register.migrate.ts",
"src/commands/migrate.ts",
"src/commands/migrate/memory-import.ts",
"src/commands/migrate/apply.ts",
"extensions/migrate-claude/provider.ts",
"extensions/migrate-claude/plan.ts",
"extensions/migrate-claude/memory.ts",
"extensions/migrate-claude/apply.ts",
"extensions/migrate-hermes/provider.ts",
"extensions/migrate-hermes/plan.ts",
"extensions/migrate-hermes/memory.ts",
"extensions/migrate-hermes/apply.ts",
"extensions/codex/src/migration/provider.ts",
"extensions/codex/src/migration/plan.ts",
"extensions/codex/src/migration/apply.ts",
),
entry(
"memory-export",
"egress",
"operator-memory-host",
"blocked-in-enforced-mode",
"extensions/memory-core/src/cli-index-search.runtime.ts",
),
entry(
"memory-public-artifact-provider-list",
"egress",
"selected-memory-plugin",
"blocked-in-enforced-mode",
"src/plugins/memory-state.ts",
"extensions/memory-core/index.ts",
"extensions/memory-lancedb/index.ts",
),
entry(
"generic-file-read",
"egress",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/agent-tools.read.ts",
"src/agents/tool-fs-policy.ts",
),
entry(
"generic-file-write",
"ingress",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/agent-tools.read.ts",
"src/agents/tool-fs-policy.ts",
),
entry(
"generic-file-edit",
"ingress",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/agent-tools.read.ts",
"src/agents/tool-fs-policy.ts",
),
entry(
"generic-file-apply-patch",
"ingress",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/apply-patch.ts",
"src/agents/tool-fs-policy.ts",
),
entry(
"sandbox-workspace-mounts",
"control",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/sandbox/workspace-mounts.ts",
),
entry(
"unsandboxed-exec",
"egress",
"core-tool-runtime",
"blocked-in-enforced-mode",
"src/agents/bash-tools.ts",
"src/agents/tool-fs-policy.ts",
),
entry(
"post-compaction-session-memory-sync",
"ingress",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/embedded-agent-runner/compaction-hooks.ts",
"src/agents/embedded-agent-runner/compaction-session-execution.ts",
"src/agents/embedded-agent-runner/compact.queued.ts",
"src/agents/embedded-agent-runner/run/timeout-context-recovery.ts",
),
entry(
"memory-flush",
"derive",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/auto-reply/reply/agent-runner-memory.ts",
"src/agents/embedded-agent-runner/compaction-hooks.ts",
"extensions/memory-core/src/flush-plan.ts",
),
entry(
"transcript-event-write",
"ingress",
"core-session-runtime",
"blocked-in-enforced-mode",
"src/agents/sessions/session-manager-persistence.ts",
"src/config/sessions/session-accessor.sqlite-transcript-write.ts",
"src/config/sessions/transcript-write-context.ts",
),
entry(
"transcript-history-and-replay",
"egress",
"core-session-runtime",
"blocked-in-enforced-mode",
"src/gateway/session-transcript-readers.ts",
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
),
entry(
"compaction-summary",
"derive",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/compaction.ts",
"src/agents/embedded-agent-runner/compaction-session-execution.ts",
),
entry(
"compaction-checkpoint",
"derive",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/embedded-agent-runner/compaction-checkpoint.ts",
"src/agents/main-session-restart-recovery-checkpoint.ts",
"src/gateway/session-compaction-checkpoints.ts",
),
entry(
"compaction-checkpoint-operator-branch-and-restore",
"control",
"operator-memory-host",
"blocked-in-enforced-mode",
"src/gateway/server-methods/sessions-compaction-checkpoints.ts",
),
entry(
"dreaming-source-recall",
"egress",
"selected-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-core/src/dreaming-phases.ts",
),
entry(
"dreaming-derived-artifacts",
"derive",
"selected-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/memory-core/src/dreaming-markdown.ts",
),
entry(
"profile-and-short-term-promotion",
"derive",
"selected-memory-plugin",
"blocked-in-enforced-mode",
"extensions/memory-core/src/short-term-promotion-apply.ts",
),
entry(
"child-agent-delegation",
"egress",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/subagent-registry.ts",
"src/agents/openclaw-tools.ts",
),
entry(
"child-agent-completion-handoff",
"egress",
"core-agent-runtime",
"blocked-in-enforced-mode",
"src/agents/subagent-announce-delivery.ts",
),
entry(
"cron-triggered-run",
"control",
"autonomous-run-host",
"blocked-in-enforced-mode",
"src/cron/service/timer-execution.ts",
),
entry(
"heartbeat-triggered-run",
"control",
"autonomous-run-host",
"blocked-in-enforced-mode",
"src/infra/heartbeat-runner.ts",
),
entry(
"webhook-triggered-run",
"control",
"autonomous-run-host",
"blocked-in-enforced-mode",
"src/gateway/server/hooks-request-handler.ts",
),
entry(
"system-triggered-run",
"control",
"autonomous-run-host",
"blocked-in-enforced-mode",
"src/gateway/server-methods/system-agent.ts",
),
entry(
"final-reply-delivery",
"egress",
"transport-egress-host",
"blocked-in-enforced-mode",
"src/auto-reply/reply/reply-delivery.ts",
),
entry(
"message-tool-delivery",
"egress",
"transport-egress-host",
"blocked-in-enforced-mode",
"src/agents/tools/message-tool.ts",
),
entry(
"session-send-delivery",
"egress",
"transport-egress-host",
"blocked-in-enforced-mode",
"src/agents/tools/sessions-send-tool.ts",
),
entry(
"plugin-and-mcp-outbound-actions",
"egress",
"transport-egress-host",
"blocked-in-enforced-mode",
"src/plugins/tools.ts",
"src/agents/mcp-transport.ts",
),
] satisfies readonly MemoryAuthorizationPathInventoryEntry[]);
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from "vitest";
import {
COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
} from "../memory-host-sdk/host/authorization.js";
import { inspectMemoryAuthorizationRuntime } from "./memory-authorization-runtime.js";
import { observeMemoryAuthorizationShadowSurface } from "./memory-authorization-shadow.js";
const AUTHORIZED_METHOD_NAMES = [
"authorize",
"searchAuthorized",
"readAuthorized",
"writeAuthorized",
"importAuthorized",
"syncAuthorized",
"exportAuthorized",
"statusAuthorized",
] as const;
function createRuntime(capabilities = COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES) {
const notCalled = vi.fn(() => {
throw new Error("runtime methods must not execute in shadow mode");
});
return {
authorization: capabilities,
authorize: notCalled,
searchAuthorized: notCalled,
readAuthorized: notCalled,
writeAuthorized: notCalled,
importAuthorized: notCalled,
syncAuthorized: notCalled,
exportAuthorized: notCalled,
statusAuthorized: notCalled,
legacyManager: { search: notCalled },
};
}
class PrototypeRuntime {
readonly authorization = COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES;
authorize() {
throw new Error("runtime methods must not execute in shadow mode");
}
searchAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
readAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
writeAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
importAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
syncAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
exportAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
statusAuthorized() {
throw new Error("runtime methods must not execute in shadow mode");
}
}
describe("memory authorization runtime inspection", () => {
it("reports a complete declared surface without calling an authorized or legacy method", () => {
const runtime = createRuntime();
const inspection = inspectMemoryAuthorizationRuntime(runtime);
expect(inspection).toMatchObject({
version: 1,
capabilityDeclaration: "complete",
declaredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length,
implementedMethodCount: AUTHORIZED_METHOD_NAMES.length,
surfaceComplete: true,
reasonCode: "surface-complete",
});
expect(inspection.missingCapabilities).toEqual([]);
expect(inspection.missingMethods).toEqual([]);
expect(Object.isFrozen(inspection)).toBe(true);
expect(Object.isFrozen(inspection.missingMethods)).toBe(true);
expect(runtime.authorize).not.toHaveBeenCalled();
expect(runtime.legacyManager.search).not.toHaveBeenCalled();
});
it("reports all-false and incomplete declarations as nonconforming", () => {
const legacy = inspectMemoryAuthorizationRuntime(
createRuntime(LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES),
);
const incomplete = inspectMemoryAuthorizationRuntime({
authorization: { ...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, scopedSync: false },
...Object.fromEntries(AUTHORIZED_METHOD_NAMES.map((name) => [name, () => undefined])),
});
expect(legacy).toMatchObject({
capabilityDeclaration: "partial",
declaredCapabilityCount: 0,
surfaceComplete: false,
reasonCode: "backend-nonconforming",
});
expect(legacy.missingCapabilities).toEqual(MEMORY_AUTHORIZATION_CAPABILITY_NAMES);
expect(incomplete).toMatchObject({
capabilityDeclaration: "partial",
declaredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length - 1,
surfaceComplete: false,
reasonCode: "backend-nonconforming",
});
expect(incomplete.missingCapabilities).toEqual(["scopedSync"]);
});
it("uses the SDK's exact capability-declaration rules for shadow reporting", () => {
const unexpectedCapability = inspectMemoryAuthorizationRuntime({
...createRuntime(),
authorization: { ...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, unexpected: true },
});
const symbolicCapability = inspectMemoryAuthorizationRuntime({
...createRuntime(),
authorization: {
...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
[Symbol("plugin-framework-metadata")]: true,
},
});
expect(unexpectedCapability).toMatchObject({
capabilityDeclaration: "malformed",
declaredCapabilityCount: 0,
reasonCode: "backend-nonconforming",
});
expect(symbolicCapability).toMatchObject({
capabilityDeclaration: "malformed",
declaredCapabilityCount: 0,
reasonCode: "backend-nonconforming",
});
});
it("accepts data descriptors from class prototypes and ignores unrelated symbols", () => {
const runtime = new PrototypeRuntime();
Object.defineProperty(runtime, Symbol("plugin-framework-metadata"), {
value: "not an authorization capability",
});
expect(inspectMemoryAuthorizationRuntime(runtime)).toMatchObject({
capabilityDeclaration: "complete",
implementedMethodCount: AUTHORIZED_METHOD_NAMES.length,
surfaceComplete: true,
reasonCode: "surface-complete",
});
});
it("fails closed on accessor and proxy surfaces without evaluating their getters or methods", () => {
let getterCalls = 0;
const accessorRuntime = Object.create(null) as Record<string, unknown>;
Object.defineProperty(accessorRuntime, "authorization", {
enumerable: true,
get() {
getterCalls += 1;
throw new Error("must not read authorization getter");
},
});
for (const name of AUTHORIZED_METHOD_NAMES) {
Object.defineProperty(accessorRuntime, name, {
enumerable: true,
get() {
getterCalls += 1;
throw new Error("must not read method getter");
},
});
}
const proxyRuntime = new Proxy(
{},
{
getOwnPropertyDescriptor() {
throw new Error("hostile proxy");
},
},
);
const declarationProxyRuntime = {
authorization: new Proxy(
{},
{
getPrototypeOf() {
throw new Error("hostile capability declaration");
},
},
),
};
const accessor = inspectMemoryAuthorizationRuntime(accessorRuntime);
const undefinedDeclaration = inspectMemoryAuthorizationRuntime({ authorization: undefined });
const proxy = inspectMemoryAuthorizationRuntime(proxyRuntime);
const declarationProxy = inspectMemoryAuthorizationRuntime(declarationProxyRuntime);
expect(getterCalls).toBe(0);
expect(accessor).toMatchObject({
capabilityDeclaration: "malformed",
reasonCode: "backend-nonconforming",
});
expect(undefinedDeclaration).toMatchObject({
capabilityDeclaration: "malformed",
reasonCode: "backend-nonconforming",
});
expect(proxy).toMatchObject({
capabilityDeclaration: "malformed",
reasonCode: "backend-nonconforming",
});
expect(declarationProxy).toMatchObject({
capabilityDeclaration: "malformed",
reasonCode: "backend-nonconforming",
});
});
});
describe("memory authorization shadow inspection", () => {
it("returns one bounded, content-free observation per selected runtime", () => {
const runtime = Object.assign(createRuntime(LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES), {
content: "private content sentinel",
prompt: "private prompt sentinel",
query: "private query sentinel",
principalId: "private principal sentinel",
});
const first = observeMemoryAuthorizationShadowSurface(runtime);
const second = observeMemoryAuthorizationShadowSurface(runtime);
expect(first).toMatchObject({
mode: "shadow",
capabilityDeclaration: "partial",
reasonCode: "backend-nonconforming",
});
expect(second).toBeUndefined();
expect(JSON.stringify(first)).not.toMatch(/private|content|prompt|query|principal/u);
});
it("does not let a hostile proxy change a selected runtime path", () => {
const runtime = new Proxy(
{},
{
getOwnPropertyDescriptor() {
throw new Error("hostile proxy");
},
},
);
const observation = observeMemoryAuthorizationShadowSurface(runtime);
expect(observation).toEqual(
expect.objectContaining({ reasonCode: "backend-nonconforming", surfaceComplete: false }),
);
});
});
+159
View File
@@ -0,0 +1,159 @@
import {
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
isMemoryAuthorizationCapabilities,
listMissingMemoryAuthorizationCapabilities,
type AuthorizedMemoryRuntime,
type MemoryAuthorizationCapabilityName,
} from "../memory-host-sdk/host/authorization.js";
const AUTHORIZED_MEMORY_RUNTIME_METHODS = [
"authorize",
"searchAuthorized",
"readAuthorized",
"writeAuthorized",
"importAuthorized",
"syncAuthorized",
"exportAuthorized",
"statusAuthorized",
] as const satisfies readonly (keyof AuthorizedMemoryRuntime)[];
type AuthorizedMemoryRuntimeMethodName = (typeof AUTHORIZED_MEMORY_RUNTIME_METHODS)[number];
export type MemoryAuthorizationRuntimeInspection = Readonly<{
version: 1;
capabilityDeclaration: "missing" | "malformed" | "partial" | "complete";
declaredCapabilityCount: number;
requiredCapabilityCount: number;
implementedMethodCount: number;
requiredMethodCount: number;
missingCapabilities: readonly MemoryAuthorizationCapabilityName[];
missingMethods: readonly AuthorizedMemoryRuntimeMethodName[];
surfaceComplete: boolean;
reasonCode: "surface-complete" | "backend-nonconforming";
}>;
// Runtime interfaces are shallow; the bound prevents hostile prototype chains from extending a
// shadow-only inspection beyond its fixed metadata budget.
const MAXIMUM_RUNTIME_PROTOTYPE_DEPTH = 8;
function isObjectReference(value: unknown): value is object {
return (typeof value === "object" && value !== null) || typeof value === "function";
}
type DataPropertyLookup =
| { kind: "data"; value: unknown }
| { kind: "missing" | "accessor" | "unavailable" };
/**
* Reads a data descriptor without evaluating the corresponding property. Runtime interfaces may
* use class methods, so the bounded prototype walk accepts data descriptors there too. A getter
* or hostile reflection failure remains nonconforming without touching an authorized or legacy
* runtime method.
*/
function readDataProperty(value: unknown, key: string): DataPropertyLookup {
if (!isObjectReference(value)) {
return { kind: "missing" };
}
try {
let current: object | null = value;
for (let depth = 0; current && depth < MAXIMUM_RUNTIME_PROTOTYPE_DEPTH; depth += 1) {
const descriptor = Object.getOwnPropertyDescriptor(current, key);
if (descriptor) {
return "value" in descriptor
? { kind: "data", value: descriptor.value }
: { kind: "accessor" };
}
current = Object.getPrototypeOf(current);
}
} catch {
// A Proxy can reject reflection. Treat it as a nonconforming declaration.
return { kind: "unavailable" };
}
return { kind: "missing" };
}
/**
* The SDK validator deliberately enforces exact descriptor shape. A plugin runtime can still be
* a hostile Proxy, so shadow inspection turns any reflection failure into a nonconforming result.
*/
function inspectCapabilityDeclaration(value: unknown): {
hasWellFormedDeclaration: boolean;
missingCapabilities: readonly MemoryAuthorizationCapabilityName[];
} {
try {
if (!isMemoryAuthorizationCapabilities(value)) {
return {
hasWellFormedDeclaration: false,
missingCapabilities: MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
};
}
return {
hasWellFormedDeclaration: true,
missingCapabilities: listMissingMemoryAuthorizationCapabilities(value),
};
} catch {
return {
hasWellFormedDeclaration: false,
missingCapabilities: MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
};
}
}
function freezeInspection(params: Omit<MemoryAuthorizationRuntimeInspection, "version">) {
return Object.freeze({
version: 1 as const,
...params,
missingCapabilities: Object.freeze([...params.missingCapabilities]),
missingMethods: Object.freeze([...params.missingMethods]),
});
}
/**
* Produces content-free shape metadata only. It is intentionally not an admission decision and
* does not retain, invoke, or wrap the inspected runtime.
*/
export function inspectMemoryAuthorizationRuntime(
runtime: unknown,
): MemoryAuthorizationRuntimeInspection {
const authorization = readDataProperty(runtime, "authorization");
const hasDeclaration = authorization.kind === "data";
// Use the shared contract validator so shadow reporting and enforced-mode admission agree on
// the exact declaration shape. This boundary catches reflection traps as malformed.
const { hasWellFormedDeclaration, missingCapabilities } = inspectCapabilityDeclaration(
hasDeclaration ? authorization.value : undefined,
);
const declaredCapabilityCount =
MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length - missingCapabilities.length;
const methods = AUTHORIZED_MEMORY_RUNTIME_METHODS.map((name) => ({
name,
property: readDataProperty(runtime, name),
}));
const implementedMethodCount = methods.filter(
({ property }) => property.kind === "data" && typeof property.value === "function",
).length;
const missingMethods = methods
.filter(({ property }) => property.kind !== "data" || typeof property.value !== "function")
.map(({ name }) => name);
const capabilityDeclaration = !hasDeclaration
? authorization.kind === "missing"
? "missing"
: "malformed"
: !hasWellFormedDeclaration
? "malformed"
: missingCapabilities.length === 0
? "complete"
: "partial";
const surfaceComplete = capabilityDeclaration === "complete" && missingMethods.length === 0;
return freezeInspection({
capabilityDeclaration,
declaredCapabilityCount,
requiredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length,
implementedMethodCount,
requiredMethodCount: AUTHORIZED_MEMORY_RUNTIME_METHODS.length,
missingCapabilities,
missingMethods,
surfaceComplete,
reasonCode: surfaceComplete ? "surface-complete" : "backend-nonconforming",
});
}
@@ -0,0 +1,55 @@
import { MEMORY_AUTHORIZATION_CONTRACT_VERSION } from "../memory-host-sdk/host/authorization.js";
import { inspectMemoryAuthorizationRuntime } from "./memory-authorization-runtime.js";
const inspectedRuntimes = new WeakSet<object>();
export type MemoryAuthorizationShadowMetadata = Readonly<{
event: "memory-authorization-backend-surface";
mode: "shadow";
contractVersion: 1;
capabilityDeclaration: "missing" | "malformed" | "partial" | "complete";
declaredCapabilityCount: number;
requiredCapabilityCount: number;
implementedMethodCount: number;
requiredMethodCount: number;
surfaceComplete: boolean;
reasonCode: "surface-complete" | "backend-nonconforming";
}>;
function isObjectReference(value: unknown): value is object {
return (typeof value === "object" && value !== null) || typeof value === "function";
}
/**
* Shadow mode returns bounded surface counts once per selected runtime. Reflection failures are
* nonconforming observations; logging remains with the runtime owner and cannot change selection.
*/
export function observeMemoryAuthorizationShadowSurface(
runtime: unknown,
): MemoryAuthorizationShadowMetadata | undefined {
if (!isObjectReference(runtime)) {
return undefined;
}
try {
if (inspectedRuntimes.has(runtime)) {
return undefined;
}
inspectedRuntimes.add(runtime);
const inspection = inspectMemoryAuthorizationRuntime(runtime);
const metadata = Object.freeze({
event: "memory-authorization-backend-surface" as const,
mode: "shadow" as const,
contractVersion: MEMORY_AUTHORIZATION_CONTRACT_VERSION,
capabilityDeclaration: inspection.capabilityDeclaration,
declaredCapabilityCount: inspection.declaredCapabilityCount,
requiredCapabilityCount: inspection.requiredCapabilityCount,
implementedMethodCount: inspection.implementedMethodCount,
requiredMethodCount: inspection.requiredMethodCount,
surfaceComplete: inspection.surfaceComplete,
reasonCode: inspection.reasonCode,
});
return metadata;
} catch {
return undefined;
}
}
+52
View File
@@ -10,6 +10,8 @@ type AuthorizeSearchHits = NonNullable<MemoryPluginRuntime["authorizeSearchHits"
const mocks = vi.hoisted(() => ({
getMemoryRuntime: vi.fn(),
loadPluginRegistryHandle: vi.fn(),
logDebug: vi.fn(),
observeMemoryAuthorizationShadowSurface: vi.fn(),
resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)),
resolveAgentWorkspaceDir: vi.fn(),
}));
@@ -23,6 +25,14 @@ vi.mock("./loader.js", () => ({
resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey,
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({ debug: mocks.logDebug })),
}));
vi.mock("./memory-authorization-shadow.js", () => ({
observeMemoryAuthorizationShadowSurface: mocks.observeMemoryAuthorizationShadowSurface,
}));
vi.mock("./memory-state.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./memory-state.js")>();
return { ...actual, getMemoryRuntime: mocks.getMemoryRuntime };
@@ -33,6 +43,7 @@ import {
closeActiveMemorySearchManagerCore,
closeActiveMemorySearchManagersCore,
getActiveMemorySearchManagerCore,
getSelectedMemoryRuntime,
resolveActiveMemoryBackendConfig,
} from "./memory-runtime.js";
import { resetStandaloneMemoryRegistrySlot } from "./memory-runtime.test-support.js";
@@ -72,6 +83,8 @@ describe("memory runtime handles", () => {
resetStandaloneMemoryRegistrySlot();
mocks.getMemoryRuntime.mockReset().mockReturnValue(undefined);
mocks.loadPluginRegistryHandle.mockReset();
mocks.logDebug.mockReset();
mocks.observeMemoryAuthorizationShadowSurface.mockReset();
mocks.resolvePluginRegistryLoadCacheKey.mockClear();
mocks.resolveAgentWorkspaceDir
.mockReset()
@@ -227,6 +240,45 @@ describe("memory runtime handles", () => {
expect(mocks.loadPluginRegistryHandle).not.toHaveBeenCalled();
});
it("inspects direct selected-runtime acquisition through the canonical seam", () => {
const runtime = createRuntime();
mocks.getMemoryRuntime.mockReturnValue(runtime);
expect(getSelectedMemoryRuntime()).toBe(runtime);
expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledOnce();
expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledWith(runtime);
});
it("wires each legacy resolution through shadow inspection without changing legacy resolution", () => {
const { registry, runtime } = createRegistry();
mocks.loadPluginRegistryHandle.mockReturnValue(registry);
expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({
backend: "builtin",
});
expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({
backend: "builtin",
});
expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledTimes(2);
expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledWith(runtime);
expect(runtime.resolveMemoryBackendConfig).toHaveBeenCalledTimes(2);
});
it("keeps legacy resolution when shadow logging fails", () => {
const { registry, runtime } = createRegistry();
mocks.loadPluginRegistryHandle.mockReturnValue(registry);
mocks.observeMemoryAuthorizationShadowSurface.mockReturnValue({ mode: "shadow" });
mocks.logDebug.mockImplementation(() => {
throw new Error("logger unavailable");
});
expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({
backend: "builtin",
});
expect(runtime.resolveMemoryBackendConfig).toHaveBeenCalledTimes(1);
});
it("authorizes raw hits inside the selected plugin runtime scope", async () => {
const { registry, runtime } = createRegistry();
runtime.authorizeSearchHits.mockImplementationOnce(async ({ hits }) => {
+37 -4
View File
@@ -1,9 +1,11 @@
// Runtime bridge for plugin-owned memory hooks and state.
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { normalizePluginsConfig } from "./config-state.js";
import { loadPluginRegistryHandle, resolvePluginRegistryLoadCacheKey } from "./loader.js";
import { observeMemoryAuthorizationShadowSurface } from "./memory-authorization-shadow.js";
import {
getMemoryRuntime,
resolveSelectedMemoryCapabilityRegistration,
@@ -20,6 +22,7 @@ type MemorySearchAuthorization = Parameters<
NonNullable<MemoryPluginRuntime["authorizeSearchHits"]>
>[0];
type MemoryRuntimeOwner = { runtime: MemoryRuntime; registry?: PluginRegistry };
const log = createSubsystemLogger("plugins/memory-authorization");
let standaloneMemoryRegistrySlot:
| { key: string; registry: PluginRegistry; retiredRuntimes: Map<MemoryRuntime, PluginRegistry> }
| undefined;
@@ -54,7 +57,7 @@ function resolveMemoryRuntimeFromRegistry(registry: PluginRegistry) {
}
function listCurrentMemoryRuntimeOwners(): MemoryRuntimeOwner[] {
const current = getMemoryRuntime();
const current = getSelectedMemoryRuntime();
const owners = new Map<MemoryRuntime, MemoryRuntimeOwner>();
for (const [runtime, registry] of standaloneMemoryRegistrySlot?.retiredRuntimes ?? []) {
owners.set(runtime, { runtime, registry });
@@ -78,11 +81,39 @@ function withMemoryRuntimeOwner<T>(
return withPluginRuntimeRegistryScope(owner.registry, () => run(owner.runtime));
}
function inspectSelectedMemoryRuntime(runtime: MemoryRuntime): MemoryRuntime {
// The inspection has no result-path effect: it only emits bounded shadow metadata once per
// selected runtime object and deliberately tolerates malformed/plugin-hostile surfaces.
const metadata = observeMemoryAuthorizationShadowSurface(runtime);
if (metadata) {
try {
log.debug("memory authorization backend surface evaluated", metadata);
} catch {
// Shadow logging must not change selected runtime resolution or a legacy result path.
}
}
return runtime;
}
/** Reads the selected registered runtime through the canonical shadow-inspected seam. */
export function getSelectedMemoryRuntime(): MemoryRuntime | undefined {
const runtime = getMemoryRuntime();
return runtime ? inspectSelectedMemoryRuntime(runtime) : undefined;
}
function toMemoryRuntimeOwner(
runtime: MemoryRuntime,
registry?: PluginRegistry,
): MemoryRuntimeOwner {
inspectSelectedMemoryRuntime(runtime);
return registry ? { runtime, registry } : { runtime };
}
function ensureMemoryRuntime(params?: {
cfg: OpenClawConfig;
agentId: string;
}): MemoryRuntimeOwner | undefined {
const current = getMemoryRuntime();
const current = getSelectedMemoryRuntime();
if (current || !params) {
return current ? { runtime: current } : undefined;
}
@@ -100,7 +131,9 @@ function ensureMemoryRuntime(params?: {
const key = resolvePluginRegistryLoadCacheKey(loadOptions);
if (standaloneMemoryRegistrySlot?.key === key) {
const runtime = resolveMemoryRuntimeFromRegistry(standaloneMemoryRegistrySlot.registry);
return runtime ? { runtime, registry: standaloneMemoryRegistrySlot.registry } : undefined;
return runtime
? toMemoryRuntimeOwner(runtime, standaloneMemoryRegistrySlot.registry)
: undefined;
}
const registry = loadPluginRegistryHandle(loadOptions);
if (!registry) {
@@ -116,7 +149,7 @@ function ensureMemoryRuntime(params?: {
retiredRuntimes.set(previousRuntime, previousSlot.registry);
}
standaloneMemoryRegistrySlot = { key, registry, retiredRuntimes };
return runtime ? { runtime, registry } : undefined;
return runtime ? toMemoryRuntimeOwner(runtime, registry) : undefined;
}
/** Returns the active plugin-backed memory search manager for an agent. */