mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(memory): complete Phase 1C read isolation
This commit is contained in:
@@ -980,26 +980,26 @@ Focused existing tests:
|
||||
|
||||
Phase 1C is complete only when all of the following are demonstrated:
|
||||
|
||||
- [ ] Every content-bearing read lane is either converted to the authorized
|
||||
- [x] Every content-bearing read lane is either converted to the authorized
|
||||
runtime or explicitly unavailable in enforced mode.
|
||||
- [ ] Through every converted lane, two verified users cannot observe one
|
||||
- [x] Through every converted lane, two verified users cannot observe one
|
||||
another's private existence bits, paths, titles, snippets, scores,
|
||||
counts, citations, cursors, or content.
|
||||
- [ ] A group view contains only its channel store, eligible shared content,
|
||||
- [x] A group view contains only its channel store, eligible shared content,
|
||||
and projections explicitly addressed to that audience.
|
||||
- [ ] Bootstrap, automatic recall, Active Memory, Memory Wiki, LanceDB, CLI,
|
||||
- [x] Bootstrap, automatic recall, Active Memory, Memory Wiki, LanceDB, CLI,
|
||||
status, session recall, and corpus supplements pass the same context and
|
||||
receipt checks or are blocked.
|
||||
- [ ] Every scoped exposure is recorded before content leaves the selected
|
||||
- [x] Every scoped exposure is recorded before content leaves the selected
|
||||
plugin, and missing/stale exposure or egress receipts are rejected.
|
||||
- [ ] The minimum transcript policy-set, run-exposure, and event-companion rows
|
||||
- [x] The minimum transcript policy-set, run-exposure, and event-companion rows
|
||||
commit atomically with each durable event after scoped exposure.
|
||||
- [ ] Enforced agents remain read-only; every ordinary, watcher, plugin,
|
||||
- [x] Enforced agents remain read-only; every ordinary, watcher, plugin,
|
||||
import, sync, and generic-file durable write path is disabled until Phase
|
||||
2A.
|
||||
- [ ] The read-lane, top-K crowd-out, plugin-failure, missing-label, and
|
||||
- [x] The read-lane, top-K crowd-out, plugin-failure, missing-label, and
|
||||
transcript-visibility test matrix passes.
|
||||
- [ ] No two-subject pilot or stronger isolation claim is enabled before Phase
|
||||
- [x] No two-subject pilot or stronger isolation claim is enabled before Phase
|
||||
1D closes raw file and exec bypasses.
|
||||
|
||||
### Phase 1C rollback
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MemoryAuthorizationCapabilities } from "openclaw/plugin-sdk/memory-authorization";
|
||||
|
||||
/** Phase 1C admits only scoped candidate search and exact opaque-handle reads. */
|
||||
/** Phase 1C admits scoped reads with authenticated exposure and egress receipts. */
|
||||
export const MEMORY_CORE_AUTHORIZATION_CAPABILITIES = Object.freeze({
|
||||
version: 1,
|
||||
scopedCandidates: true,
|
||||
@@ -10,6 +10,6 @@ export const MEMORY_CORE_AUTHORIZATION_CAPABILITIES = Object.freeze({
|
||||
scopedImport: false,
|
||||
scopedExport: false,
|
||||
scopedStatus: false,
|
||||
exposureReceipts: false,
|
||||
egressReceipts: false,
|
||||
exposureReceipts: true,
|
||||
egressReceipts: true,
|
||||
}) satisfies MemoryAuthorizationCapabilities;
|
||||
|
||||
@@ -363,10 +363,7 @@ describe("memory cli", () => {
|
||||
["index", ["index", "--agent", "main"]],
|
||||
["promotion apply", ["promote", "--agent", "main", "--apply"]],
|
||||
["REM backfill", ["rem-backfill", "--agent", "main", "--rollback"]],
|
||||
[
|
||||
"session backfill apply",
|
||||
["session-backfill", "--agent", "main", "--apply"],
|
||||
],
|
||||
["session backfill apply", ["session-backfill", "--agent", "main", "--apply"]],
|
||||
] as const)(
|
||||
"blocks %s before acquiring a legacy manager for a cut-over agent",
|
||||
async (_command, args) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
consumeAdmittedChannelMemoryIdentityFromContext,
|
||||
createChannelMemoryIdentityAdmission,
|
||||
} from "../../../../src/channels/message-access/memory-identity-admission.js";
|
||||
import { admitMemoryAuthorizationReadRuntime } from "../../../../src/plugins/memory-authorization-runtime.js";
|
||||
import { resetMemoryIsolationCutoverForTest } from "../../../../src/plugins/memory-cutover.js";
|
||||
import { createEmptyPluginRegistry } from "../../../../src/plugins/registry-empty.js";
|
||||
import {
|
||||
@@ -259,6 +260,10 @@ describe("builtin scoped authorized runtime", () => {
|
||||
}
|
||||
|
||||
it("postfilters before result count and issues plan-bound exact-read handles", async () => {
|
||||
expect(MEMORY_CORE_AUTHORIZATION_CAPABILITIES).toMatchObject({
|
||||
exposureReceipts: true,
|
||||
egressReceipts: true,
|
||||
});
|
||||
const alice = createPrivateResource("alice", "shared signal from alice");
|
||||
createPrivateResource("bob", "shared signal from bob");
|
||||
const context = createContext("alice");
|
||||
@@ -273,6 +278,13 @@ describe("builtin scoped authorized runtime", () => {
|
||||
expect(result.value).toHaveLength(1);
|
||||
expect(result.value[0]).toMatchObject({ snippet: "shared signal from alice" });
|
||||
expect(result.exposureReceipt.exposedRevisionHandles).toEqual([alice.revisionId]);
|
||||
expect(result.egressReceipt).toMatchObject({
|
||||
planId: plan.planId,
|
||||
runId: context.runId,
|
||||
allowedAudiences: context.delivery.audiences,
|
||||
deliveryRevision: context.delivery.deliveryRevision,
|
||||
egressRegistryRevision: context.delivery.egressRegistryRevision,
|
||||
});
|
||||
const hit = result.value[0];
|
||||
if (!hit) {
|
||||
return;
|
||||
@@ -307,6 +319,13 @@ describe("builtin scoped authorized runtime", () => {
|
||||
}
|
||||
markCutOver();
|
||||
installBuiltinSelectedRuntime();
|
||||
await expect(
|
||||
admitMemoryAuthorizationReadRuntime({
|
||||
authorization: MEMORY_CORE_AUTHORIZATION_CAPABILITIES,
|
||||
authorizationConformance: builtinScopedMemoryConformanceAdapter,
|
||||
runtime: builtinScopedMemoryAuthorizedRuntime,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const aliceHost = createAuthorizedMemoryReadHost({ agentId: "main", ...aliceSession });
|
||||
const bobHost = createAuthorizedMemoryReadHost({ agentId: "main", ...bobSession });
|
||||
|
||||
@@ -176,8 +176,15 @@ export async function searchMemoryCorpusSupplements(params: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
memoryReadEnforced?: OpenClawPluginToolContext["memoryReadEnforced"];
|
||||
authorizedMemoryRead?: OpenClawPluginToolContext["authorizedMemoryRead"];
|
||||
corpus?: "memory" | "wiki" | "all" | "sessions";
|
||||
}): Promise<MemoryCorpusSearchResult[]> {
|
||||
if (params.memoryReadEnforced) {
|
||||
// Supplemental corpora have no selected-runtime source contract yet. A
|
||||
// host handle is intentionally not reinterpreted as authority for them.
|
||||
return [];
|
||||
}
|
||||
if (params.corpus === "memory" || params.corpus === "sessions") {
|
||||
return [];
|
||||
}
|
||||
@@ -207,8 +214,14 @@ export async function getMemoryCorpusSupplementResult(params: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
memoryReadEnforced?: OpenClawPluginToolContext["memoryReadEnforced"];
|
||||
authorizedMemoryRead?: OpenClawPluginToolContext["authorizedMemoryRead"];
|
||||
corpus?: "memory" | "wiki" | "all" | "sessions";
|
||||
}) {
|
||||
if (params.memoryReadEnforced) {
|
||||
// See search: enforce the same unavailable result before any plugin call.
|
||||
return null;
|
||||
}
|
||||
if (params.corpus === "memory" || params.corpus === "sessions") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
// Memory Core tests cover tools plugin behavior.
|
||||
import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-host-core";
|
||||
import {
|
||||
clearMemoryPluginState,
|
||||
registerMemoryCorpusSupplement,
|
||||
} from "openclaw/plugin-sdk/memory-host-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMemoryCloseMockCalls,
|
||||
@@ -24,8 +27,10 @@ import {
|
||||
} from "./tools.js";
|
||||
import {
|
||||
buildMemorySearchUnavailableResult,
|
||||
getMemoryCorpusSupplementResult,
|
||||
MemoryGetSchema,
|
||||
MemorySearchSchema,
|
||||
searchMemoryCorpusSupplements,
|
||||
} from "./tools.shared.js";
|
||||
import {
|
||||
asOpenClawConfig,
|
||||
@@ -782,6 +787,93 @@ describe("enforced memory tools", () => {
|
||||
});
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks the wiki corpus instead of remapping it to authorized memory sources", async () => {
|
||||
const host = {
|
||||
search: vi.fn(async () => ({ results: [] })),
|
||||
read: vi.fn(async () => ({ text: "unused", path: "memory/MEMORY.md" })),
|
||||
};
|
||||
const search = createMemorySearchToolOrThrow({
|
||||
memoryReadEnforced: true,
|
||||
authorizedMemoryRead: host,
|
||||
});
|
||||
|
||||
const result = await search.execute("scoped-wiki", { query: "private wiki", corpus: "wiki" });
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
disabled: true,
|
||||
unavailable: true,
|
||||
results: [],
|
||||
error: "The wiki corpus is unavailable for scoped memory.",
|
||||
});
|
||||
expect(host.search).not.toHaveBeenCalled();
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("routes enforced conversation recall only through the authorized sessions source", async () => {
|
||||
const host = {
|
||||
search: vi.fn(async () => ({ results: [] })),
|
||||
read: vi.fn(async () => ({ text: "unused", path: "sessions/unused.jsonl" })),
|
||||
};
|
||||
const search = createMemorySearchToolOrThrow({
|
||||
memoryReadEnforced: true,
|
||||
authorizedMemoryRead: host,
|
||||
conversationRecall: {
|
||||
anchorSessionKey: "agent:main:main",
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
},
|
||||
});
|
||||
|
||||
await search.execute("scoped-conversation-recall", { query: "prior turn", corpus: "memory" });
|
||||
|
||||
expect(host.search).toHaveBeenCalledWith({
|
||||
query: "prior turn",
|
||||
sources: ["sessions"],
|
||||
limit: undefined,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not invoke registered corpus supplements after cutover", async () => {
|
||||
const supplement = {
|
||||
search: vi.fn(async () => [
|
||||
{ corpus: "wiki", path: "private.md", score: 1, snippet: "private" },
|
||||
]),
|
||||
get: vi.fn(async () => ({
|
||||
corpus: "wiki",
|
||||
path: "private.md",
|
||||
content: "private",
|
||||
fromLine: 1,
|
||||
lineCount: 1,
|
||||
})),
|
||||
};
|
||||
registerMemoryCorpusSupplement("memory-wiki", supplement);
|
||||
const host = {
|
||||
search: vi.fn(async () => ({ results: [] })),
|
||||
read: vi.fn(async () => ({ text: "", path: "" })),
|
||||
};
|
||||
|
||||
await expect(
|
||||
searchMemoryCorpusSupplements({
|
||||
query: "private",
|
||||
corpus: "all",
|
||||
memoryReadEnforced: true,
|
||||
authorizedMemoryRead: host,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
getMemoryCorpusSupplementResult({
|
||||
lookup: "private.md",
|
||||
corpus: "wiki",
|
||||
memoryReadEnforced: true,
|
||||
authorizedMemoryRead: host,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(supplement.search).not.toHaveBeenCalled();
|
||||
expect(supplement.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory_search corpus labels", () => {
|
||||
|
||||
@@ -75,15 +75,28 @@ function authorizedMemoryUnavailableResult() {
|
||||
return jsonResult(buildMemorySearchUnavailableResult("memory unavailable"));
|
||||
}
|
||||
|
||||
function authorizedMemoryCorpusUnavailableResult(corpus: "wiki") {
|
||||
return jsonResult(
|
||||
buildMemorySearchUnavailableResult(`The ${corpus} corpus is unavailable for scoped memory.`, {
|
||||
warning:
|
||||
"Compiled wiki memory is unavailable because scoped memory admits only authorized memory and session sources.",
|
||||
action: "Use corpus=memory or corpus=sessions, or request an authorized memory source.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuthorizedMemorySources(
|
||||
requestedCorpus: "memory" | "wiki" | "all" | "sessions" | undefined,
|
||||
): readonly MemorySource[] {
|
||||
): readonly MemorySource[] | undefined {
|
||||
if (requestedCorpus === "memory") {
|
||||
return ["memory"];
|
||||
}
|
||||
if (requestedCorpus === "sessions") {
|
||||
return ["sessions"];
|
||||
}
|
||||
if (requestedCorpus === "wiki") {
|
||||
return undefined;
|
||||
}
|
||||
// `all` has no supplemental-plugin escape hatch in enforced mode. It means
|
||||
// all stores admitted by the selected runtime, across its content sources.
|
||||
return ["memory", "sessions"];
|
||||
@@ -286,6 +299,8 @@ async function getSupplementMemoryReadResult(params: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
memoryReadEnforced?: OpenClawPluginToolContext["memoryReadEnforced"];
|
||||
authorizedMemoryRead?: OpenClawPluginToolContext["authorizedMemoryRead"];
|
||||
corpus?: "memory" | "wiki" | "all";
|
||||
}) {
|
||||
const supplement = await getMemoryCorpusSupplementResult({
|
||||
@@ -295,6 +310,8 @@ async function getSupplementMemoryReadResult(params: {
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed,
|
||||
memoryReadEnforced: params.memoryReadEnforced,
|
||||
authorizedMemoryRead: params.authorizedMemoryRead,
|
||||
corpus: params.corpus,
|
||||
});
|
||||
if (!supplement) {
|
||||
@@ -403,7 +420,7 @@ export function createMemorySearchTool(options: {
|
||||
label: "Memory Search",
|
||||
name: "memory_search",
|
||||
description:
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true or stale=true, you must tell the user and include the warning/action guidance.",
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. In legacy mode, `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements; scoped memory makes `corpus=wiki` unavailable and admits only authorized memory/session sources. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true or stale=true, you must tell the user and include the warning/action guidance.",
|
||||
parameters: MemorySearchSchema,
|
||||
execute:
|
||||
({ cfg, agentId }) =>
|
||||
@@ -425,13 +442,17 @@ export function createMemorySearchTool(options: {
|
||||
const requestedCorpus =
|
||||
options.conversationRecall?.corpus === "sessions" ? "sessions" : modelRequestedCorpus;
|
||||
if (options.memoryReadEnforced) {
|
||||
const sources = resolveAuthorizedMemorySources(requestedCorpus);
|
||||
if (!sources) {
|
||||
return authorizedMemoryCorpusUnavailableResult("wiki");
|
||||
}
|
||||
const host = options.authorizedMemoryRead;
|
||||
if (!host) {
|
||||
return authorizedMemoryUnavailableResult();
|
||||
}
|
||||
const authorized = await host.search({
|
||||
query,
|
||||
sources: resolveAuthorizedMemorySources(requestedCorpus),
|
||||
sources,
|
||||
limit: maxResults,
|
||||
signal: callerSignal,
|
||||
});
|
||||
@@ -668,6 +689,8 @@ export function createMemorySearchTool(options: {
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
sandboxed: options.sandboxed,
|
||||
memoryReadEnforced: options.memoryReadEnforced,
|
||||
authorizedMemoryRead: options.authorizedMemoryRead,
|
||||
corpus: requestedCorpus,
|
||||
}),
|
||||
),
|
||||
@@ -783,6 +806,8 @@ export function createMemoryGetTool(options: {
|
||||
agentId,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
sandboxed: options.sandboxed,
|
||||
memoryReadEnforced: options.memoryReadEnforced,
|
||||
authorizedMemoryRead: options.authorizedMemoryRead,
|
||||
corpus: requestedCorpus,
|
||||
});
|
||||
return jsonResult(
|
||||
|
||||
@@ -76,7 +76,9 @@ describe("syncMemoryWikiBridgeSources", () => {
|
||||
});
|
||||
const stat = vi.spyOn(fs, "stat");
|
||||
const mkdir = vi.spyOn(fs, "mkdir");
|
||||
isLegacyMemorySurfaceDisabledMock.mockImplementation((agentId: string) => agentId === "cutover");
|
||||
isLegacyMemorySurfaceDisabledMock.mockImplementation(
|
||||
(agentId: string) => agentId === "cutover",
|
||||
);
|
||||
|
||||
await expect(
|
||||
syncMemoryWikiBridgeSources({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { assertLegacyMemoryWikiAccessAvailable } from "./legacy-memory-access.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import {
|
||||
createWikiPageFilename,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
readMemoryWikiSourceSyncState,
|
||||
writeMemoryWikiSourceSyncState,
|
||||
} from "./source-sync-state.js";
|
||||
import { assertLegacyMemoryWikiAccessAvailable } from "./legacy-memory-access.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
type BridgeArtifact = {
|
||||
|
||||
+17
-12
@@ -70,7 +70,6 @@ import {
|
||||
filterLocalModelLeanTools,
|
||||
resolveLocalModelLeanPreserveToolNames,
|
||||
} from "./local-model-lean.js";
|
||||
import { createMemoryFileMutationGuard } from "./memory-file-mutation-guard.js";
|
||||
import { createMemoryWriteProvenanceObserver } from "./memory-write-provenance.js";
|
||||
import type { ModelAuthMode } from "./model-auth.js";
|
||||
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
|
||||
@@ -517,9 +516,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
const codingRoot = sandboxRoot ?? runtimeRoot;
|
||||
const containmentRoot = sandboxRoot ?? sessionPermissionPolicy?.root ?? codingRoot;
|
||||
const memoryFlushWriteRoot = sandboxRoot ?? workspaceRoot;
|
||||
const memoryFileMutationGuard = isMemoryIsolationCutoverAgent(agentId)
|
||||
? createMemoryFileMutationGuard({ mutationRoot: memoryFlushWriteRoot })
|
||||
: undefined;
|
||||
const memoryIsolationCutover = Boolean(agentId && isMemoryIsolationCutoverAgent(agentId));
|
||||
// Flush exposes one append-only target; its fallback records inherited taint after success.
|
||||
const memoryWriteProvenance = isMemoryFlushRun
|
||||
? undefined
|
||||
@@ -541,7 +538,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
includePluginTools: true,
|
||||
};
|
||||
const includeBaseCodingTools = includeCoreTools && toolConstructionPlan.includeBaseCodingTools;
|
||||
const includeShellTools = includeCoreTools && toolConstructionPlan.includeShellTools;
|
||||
// P1C's selected-memory pilot is read-only. Hiding both the shell and its process controller
|
||||
// closes the generic durable-write bypass without pretending this is P1D virtual-FS confinement.
|
||||
const includeShellTools =
|
||||
includeCoreTools && toolConstructionPlan.includeShellTools && !memoryIsolationCutover;
|
||||
const includeOpenClawTools = includeCoreTools && toolConstructionPlan.includeOpenClawTools;
|
||||
const includeChannelTools = toolConstructionPlan.includeChannelTools;
|
||||
const includePluginTools = toolConstructionPlan.includePluginTools;
|
||||
@@ -584,7 +584,6 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
skillsSnapshot: options?.skillsSnapshot,
|
||||
modelContextWindowTokens: options?.modelContextWindowTokens,
|
||||
imageSanitization,
|
||||
memoryFileMutationGuard,
|
||||
memoryWriteProvenance,
|
||||
...(includeBaseCodingTools
|
||||
? { baseToolNames: createCodingTools(codingRoot).map((tool) => tool.name) }
|
||||
@@ -952,20 +951,26 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
!options?.swarmCollector ||
|
||||
(tool.name !== "ask_user" && tool.name !== "sessions_send" && tool.name !== "sessions_yield"),
|
||||
);
|
||||
// P1C has no authorized mutation or execution path. Apply this after every contributor and
|
||||
// policy layer so a future core, plugin, or ring-zero tool cannot reopen a durable-write bypass.
|
||||
const surfaceTools = memoryIsolationCutover
|
||||
? authorizedTools.filter((tool) => tool.name === "read")
|
||||
: authorizedTools;
|
||||
if (
|
||||
swarmStructuredOutputTool &&
|
||||
!authorizedTools.some((tool) => tool.name === swarmStructuredOutputTool.name)
|
||||
!memoryIsolationCutover &&
|
||||
!surfaceTools.some((tool) => tool.name === swarmStructuredOutputTool.name)
|
||||
) {
|
||||
// Collector output is a run contract, not an operator-configurable capability.
|
||||
authorizedTools.push(swarmStructuredOutputTool);
|
||||
surfaceTools.push(swarmStructuredOutputTool);
|
||||
}
|
||||
processToolAvailabilityRef.value = authorizedTools.some((tool) => tool.name === "process");
|
||||
if (shouldInheritEffectiveToolAllowlist) {
|
||||
// Snapshot exporter only: this copies authorizedTools for descendants and
|
||||
// Snapshot exporter only: this copies surfaceTools for descendants and
|
||||
// never filters the mandatory structured_output tool from this turn.
|
||||
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, authorizedTools);
|
||||
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, surfaceTools);
|
||||
}
|
||||
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, authorizedTools, (tool) =>
|
||||
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, surfaceTools, (tool) =>
|
||||
getPluginToolMeta(tool),
|
||||
);
|
||||
options?.recordToolPrepStage?.("authorization-policy");
|
||||
@@ -1007,7 +1012,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
};
|
||||
// NOTE: Keep canonical (lowercase) tool names here. Provider transports remap on the wire.
|
||||
return finalizeAgentTools({
|
||||
tools: authorizedTools,
|
||||
tools: surfaceTools,
|
||||
modelProvider: options?.modelProvider,
|
||||
modelId: options?.modelId,
|
||||
modelCompat: options?.modelCompat,
|
||||
|
||||
@@ -10,7 +10,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetMemoryIsolationCutoverForTest } from "../plugins/memory-cutover.js";
|
||||
import { createCanonicalFixtureSkill } from "../skills/test-support/test-helpers.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { createOpenClawCodingTools } from "./agent-tools.js";
|
||||
import {
|
||||
createHostWorkspaceEditTool,
|
||||
@@ -84,6 +89,54 @@ async function expectExecCwdResolvesTo(
|
||||
}
|
||||
|
||||
describe("workspace path resolution", () => {
|
||||
it("exposes only read for an enforced read-only memory agent", async () => {
|
||||
await withTempDir("openclaw-memory-cutover-state-", async (stateDir) => {
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main" });
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO memory_migrations
|
||||
(migration_id, source_kind, source_hash, phase, classification_json, plan_hash,
|
||||
verified_at, cutover_at, updated_at)
|
||||
VALUES ('memory-cutover-shell-tools', 'test', 'test-source', 'cutover', '{}',
|
||||
'test-plan', 1, 1, 1)`,
|
||||
)
|
||||
.run();
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
|
||||
expect(createOpenClawCodingTools({ agentId: "main" }).map((tool) => tool.name)).toEqual([
|
||||
"read",
|
||||
]);
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy memory-file writes for intentionally unscoped tool construction", async () => {
|
||||
await withTempDir("openclaw-unscoped-ws-", async (workspaceDir) => {
|
||||
const tools = createOpenClawCodingTools({ workspaceDir });
|
||||
const { writeTool } = expectReadWriteEditTools(tools);
|
||||
|
||||
await writeTool.execute("unscoped-memory-write", {
|
||||
path: "MEMORY.md",
|
||||
content: "legacy utility state",
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8")).resolves.toBe(
|
||||
"legacy utility state",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps controlled memory roots read-only while preserving ordinary host and sandbox writes", async () => {
|
||||
await withTempDir("openclaw-memory-guard-host-", async (workspaceDir) => {
|
||||
const guard = createMemoryFileMutationGuard({ mutationRoot: workspaceDir });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveProviderSystemPromptContribution,
|
||||
transformProviderSystemPrompt,
|
||||
} from "../../../plugins/provider-runtime.js";
|
||||
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
|
||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { listActiveProcessSessionReferences } from "../../bash-process-references.js";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from "../../channel-tools.js";
|
||||
import { resolveOpenClawReferencePaths } from "../../docs-path.js";
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
|
||||
import { createAuthorizedMemoryReadHost } from "../../memory-authorized-read-host.js";
|
||||
import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js";
|
||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||
import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js";
|
||||
@@ -248,6 +250,20 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
});
|
||||
const includeMemorySection =
|
||||
!params.activeContextEngine || params.activeContextEngine.info.id === "legacy";
|
||||
const authorizedMemoryRead = createAuthorizedMemoryReadHost({
|
||||
agentId: params.sessionAgentId,
|
||||
sessionKey: runtimeInfo.sessionKey,
|
||||
sessionId: attempt.sessionId,
|
||||
runId: attempt.runId,
|
||||
deliveryContext: normalizeDeliveryContext({
|
||||
channel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
to: attempt.messageTo ?? attempt.currentMessagingTarget ?? attempt.currentChannelId,
|
||||
accountId: attempt.agentAccountId,
|
||||
threadId: attempt.messageThreadId ?? attempt.currentThreadTs,
|
||||
}),
|
||||
messageChannel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
agentAccountId: attempt.agentAccountId,
|
||||
});
|
||||
const preparedMemoryPrompt = await prepareAgentMemoryPrompt({
|
||||
enabled: effectivePromptMode === "full" && includeMemorySection,
|
||||
toolNames: params.effectiveTools.map((tool) => tool.name),
|
||||
@@ -256,6 +272,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
agentId: runtimeInfo.agentId,
|
||||
agentSessionKey: runtimeInfo.sessionKey,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
authorizedMemoryRead,
|
||||
});
|
||||
const preparedWatchedSessions = prepareWatchedSessionsPrompt({
|
||||
enabled: effectivePromptMode === "full",
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
createAuthorizedMemoryReadInvocation,
|
||||
readAuthorizedMemoryForInvocation,
|
||||
readAuthorizedMemoryTranscriptExposure,
|
||||
searchAuthorizedMemoryForInvocation,
|
||||
type AuthorizedMemoryReadInvocation,
|
||||
} from "../plugins/memory-invocation.js";
|
||||
import { recordMemoryRunExposure } from "../plugins/memory-run-exposure.js";
|
||||
import type { AuthorizedMemoryReadHost } from "../plugins/tool-types.js";
|
||||
import {
|
||||
captureTrustedMemoryAccessFacts,
|
||||
@@ -206,18 +204,6 @@ export function createAuthorizedMemoryReadHost(params: {
|
||||
| undefined;
|
||||
const getInvocation = () =>
|
||||
(invocation ??= createAuthorizedMemoryReadInvocation({ context: trusted.context }));
|
||||
const recordExposure = (active: AuthorizedMemoryReadInvocation): boolean => {
|
||||
const exposure = readAuthorizedMemoryTranscriptExposure(active);
|
||||
if (
|
||||
!exposure ||
|
||||
exposure.agentId !== context.agentId ||
|
||||
exposure.sessionId !== context.sessionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
recordMemoryRunExposure(exposure);
|
||||
return true;
|
||||
};
|
||||
return Object.freeze({
|
||||
async search(search) {
|
||||
const active = await getInvocation();
|
||||
@@ -225,9 +211,7 @@ export function createAuthorizedMemoryReadHost(params: {
|
||||
return active;
|
||||
}
|
||||
const result = await searchAuthorizedMemoryForInvocation({ invocation: active, ...search });
|
||||
return "unavailable" in result || !recordExposure(active)
|
||||
? MEMORY_INVOCATION_UNAVAILABLE
|
||||
: result;
|
||||
return "unavailable" in result ? MEMORY_INVOCATION_UNAVAILABLE : result;
|
||||
},
|
||||
async read(read) {
|
||||
const active = await getInvocation();
|
||||
@@ -235,9 +219,7 @@ export function createAuthorizedMemoryReadHost(params: {
|
||||
return active;
|
||||
}
|
||||
const result = await readAuthorizedMemoryForInvocation({ invocation: active, ...read });
|
||||
return "unavailable" in result || !recordExposure(active)
|
||||
? MEMORY_INVOCATION_UNAVAILABLE
|
||||
: result;
|
||||
return "unavailable" in result ? MEMORY_INVOCATION_UNAVAILABLE : result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { MemoryCitationsMode } from "../config/types.memory.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import {
|
||||
prepareMemoryPromptSection,
|
||||
type PreparedMemoryPromptSection,
|
||||
} from "../plugins/memory-state.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import type { AuthorizedMemoryReadHost } from "../plugins/tool-types.js";
|
||||
|
||||
/** Prepare memory prompt state with the same normalized tool context used by assembly. */
|
||||
export async function prepareAgentMemoryPrompt(params: {
|
||||
@@ -14,6 +15,8 @@ export async function prepareAgentMemoryPrompt(params: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
/** Host-minted selected-runtime invocation for this exact prompt run. */
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
}): Promise<PreparedMemoryPromptSection | undefined> {
|
||||
if (!params.enabled) {
|
||||
return undefined;
|
||||
@@ -29,6 +32,7 @@ export async function prepareAgentMemoryPrompt(params: {
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed,
|
||||
authorizedMemoryRead: params.authorizedMemoryRead,
|
||||
...(params.agentId && isMemoryIsolationCutoverAgent(params.agentId)
|
||||
? { memoryReadEnforced: true as const }
|
||||
: {}),
|
||||
|
||||
@@ -311,6 +311,8 @@ function buildMemorySection(params: {
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed,
|
||||
memoryReadEnforced: params.prepared?.context.memoryReadEnforced ? true : undefined,
|
||||
authorizedMemoryRead: params.prepared?.context.authorizedMemoryRead,
|
||||
},
|
||||
params.prepared,
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runDoctorMemoryIsolation } from "../../commands/doctor-memory-isolation.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resetMemoryIsolationCutoverForTest } from "../../plugins/memory-cutover.js";
|
||||
import { persistMemoryRunExposureBeforeContentInDatabase } from "../../plugins/memory-run-exposure-ledger.js";
|
||||
import {
|
||||
clearMemoryRunExposureForTest,
|
||||
recordMemoryRunExposure,
|
||||
} from "../../plugins/memory-run-exposure.js";
|
||||
import { createCurrentMemorySessionContext } from "../../state/memory-session-subject.js";
|
||||
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
@@ -107,6 +109,17 @@ function recordExposure(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function persistExposure(
|
||||
database: OpenClawAgentDatabase,
|
||||
params: Parameters<typeof recordExposure>[0],
|
||||
) {
|
||||
const exposure = recordExposure(params);
|
||||
expect(persistMemoryRunExposureBeforeContentInDatabase({ database, snapshot: exposure })).toBe(
|
||||
true,
|
||||
);
|
||||
return exposure;
|
||||
}
|
||||
|
||||
async function appendWithRun(params: { env: NodeJS.ProcessEnv; runId: string; text: string }) {
|
||||
await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
@@ -156,11 +169,21 @@ describe("transcript memory policy companions", () => {
|
||||
);
|
||||
|
||||
writeSession(alice);
|
||||
const database = openOpenClawAgentDatabase(options);
|
||||
const aliceContext = createCurrentMemorySessionContext({ ...alice, options });
|
||||
expect(aliceContext.kind).toBe("current");
|
||||
if (aliceContext.kind !== "current") {
|
||||
throw new Error("expected lifecycle-owned Alice subject context");
|
||||
}
|
||||
await appendSqliteTranscriptMessage(
|
||||
{ ...alice, agentId: AGENT_ID, env },
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "legacy shadow search content" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR ?? "");
|
||||
expect(
|
||||
@@ -173,8 +196,16 @@ describe("transcript memory policy companions", () => {
|
||||
// Doctor writes out of process. Refresh the process-owned snapshot to model the required
|
||||
// Gateway restart before proving the protected transcript boundary.
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
resetTranscriptMemoryPolicyForTest(database.db);
|
||||
expect(
|
||||
searchSessionTranscripts({
|
||||
agentId: AGENT_ID,
|
||||
env,
|
||||
query: "legacy shadow search content",
|
||||
}).hits,
|
||||
).toEqual([]);
|
||||
|
||||
recordMemoryRunExposure({
|
||||
const shadowExposure = recordMemoryRunExposure({
|
||||
agentId: AGENT_ID,
|
||||
sessionId: alice.sessionId,
|
||||
sessionKey: alice.sessionKey,
|
||||
@@ -192,6 +223,9 @@ describe("transcript memory policy companions", () => {
|
||||
sessionIdentityRevision: aliceContext.context.sessionIdentityRevision,
|
||||
subjectRevision: aliceContext.context.subjectRevision,
|
||||
});
|
||||
expect(
|
||||
persistMemoryRunExposureBeforeContentInDatabase({ database, snapshot: shadowExposure }),
|
||||
).toBe(true);
|
||||
await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionTarget: {
|
||||
@@ -214,7 +248,6 @@ describe("transcript memory policy companions", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
const database = openOpenClawAgentDatabase(options);
|
||||
expect(readAuthorizedTranscriptEventSeqs(database.db, alice.sessionId)?.size).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
@@ -225,6 +258,13 @@ describe("transcript memory policy companions", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const searchAlice = () =>
|
||||
searchSessionTranscripts({ agentId: AGENT_ID, env, query: "alice scoped content" });
|
||||
await vi.waitFor(() => expect(searchAlice().indexing).toBe(false), {
|
||||
interval: 10,
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(searchAlice().hits).toHaveLength(1);
|
||||
|
||||
writeSession(bob);
|
||||
expect(createCurrentMemorySessionContext({ ...bob, options })).toEqual({
|
||||
@@ -252,9 +292,9 @@ describe("transcript memory policy companions", () => {
|
||||
await appendSqliteTranscriptMessage(scope(env), {
|
||||
message: { role: "assistant", content: [{ type: "text", text: "missing exposure content" }] },
|
||||
});
|
||||
recordExposure({ runId: "stale-run", subjectRevision: "stale-subject-revision" });
|
||||
persistExposure(database, { runId: "stale-run", subjectRevision: "stale-subject-revision" });
|
||||
await appendWithRun({ env, runId: "stale-run", text: "stale exposure content" });
|
||||
recordExposure({ runId: "authorized-run" });
|
||||
const authorizedExposure = persistExposure(database, { runId: "authorized-run" });
|
||||
await appendWithRun({ env, runId: "authorized-run", text: "authorized exposure content" });
|
||||
|
||||
const policyRows = database.db
|
||||
@@ -266,7 +306,10 @@ describe("transcript memory policy companions", () => {
|
||||
)
|
||||
.all(SESSION_ID) as Array<{ authorization_status: string; run_id: string | null }>;
|
||||
expect(policyRows.filter((row) => row.authorization_status === "authorized")).toEqual([
|
||||
{ authorization_status: "authorized", run_id: "authorized-run" },
|
||||
{
|
||||
authorization_status: "authorized",
|
||||
run_id: authorizedExposure.durableRunScopeId,
|
||||
},
|
||||
]);
|
||||
expect(policyRows.filter((row) => row.authorization_status === "pending")).toHaveLength(2);
|
||||
expect(database.db.prepare("SELECT COUNT(*) AS count FROM memory_policy_sets").get()).toEqual({
|
||||
@@ -320,7 +363,7 @@ describe("transcript memory policy companions", () => {
|
||||
it("does not derive an append parent from a pending transcript event", async () => {
|
||||
const env = createEnv();
|
||||
const database = markCutOver(env);
|
||||
recordExposure({ runId: "authorized-run" });
|
||||
persistExposure(database, { runId: "authorized-run" });
|
||||
let authorizedMessageId: string | undefined;
|
||||
await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
@@ -371,7 +414,7 @@ describe("transcript memory policy companions", () => {
|
||||
it("rolls the event and every companion row back when companion persistence fails", async () => {
|
||||
const env = createEnv();
|
||||
const database = markCutOver(env);
|
||||
recordExposure({ runId: "authorized-run" });
|
||||
persistExposure(database, { runId: "authorized-run" });
|
||||
database.db.exec(/* sqlite-allow-raw: test-only atomicity fault injection. */ `
|
||||
CREATE TRIGGER reject_transcript_memory_policy_for_test
|
||||
BEFORE INSERT ON transcript_event_memory_policies
|
||||
@@ -394,12 +437,18 @@ describe("transcript memory policy companions", () => {
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
// The pre-output ledger commits before content leaves the memory broker, outside this
|
||||
// transcript transaction; a later companion rollback must not erase its audit fact.
|
||||
expect(
|
||||
database.db.prepare("SELECT COUNT(*) AS count FROM memory_preoutput_exposure_ledger").get(),
|
||||
).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
it("replays only committed current companions after a fresh database consumer starts", async () => {
|
||||
const env = createEnv();
|
||||
const database = markCutOver(env);
|
||||
recordExposure({ runId: "authorized-run" });
|
||||
persistExposure(database, { runId: "authorized-run" });
|
||||
clearMemoryRunExposureForTest();
|
||||
await appendWithRun({ env, runId: "authorized-run", text: "committed companion content" });
|
||||
|
||||
const committedRows = database.db
|
||||
@@ -496,7 +545,7 @@ describe("transcript memory policy companions", () => {
|
||||
it("removes a stale companion from replay, search, projections, compaction, and export", async () => {
|
||||
const env = createEnv();
|
||||
const database = markCutOver(env);
|
||||
recordExposure({ runId: "authorized-run" });
|
||||
persistExposure(database, { runId: "authorized-run" });
|
||||
await appendWithRun({ env, runId: "authorized-run", text: "stale companion secret" });
|
||||
|
||||
const search = () =>
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
getNodeSqliteKysely,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { isMemoryIsolationTranscriptPolicyEnforcedInDatabase } from "../../plugins/memory-cutover.js";
|
||||
import {
|
||||
readMemoryRunExposure,
|
||||
type MemoryRunExposureSnapshot,
|
||||
} from "../../plugins/memory-run-exposure.js";
|
||||
import { readDurableMemoryRunExposure } from "../../plugins/memory-run-exposure-ledger.js";
|
||||
import { type MemoryRunExposureSnapshot } from "../../plugins/memory-run-exposure.js";
|
||||
import type { DB as OpenClawAgentDatabaseSchema } from "../../state/openclaw-agent-db.generated.js";
|
||||
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
|
||||
import { getOwnedSessionTranscriptWriterFence } from "./transcript-write-context.js";
|
||||
@@ -147,7 +145,7 @@ function persistExposureLineageInTransaction(params: {
|
||||
.values({
|
||||
exposure_set_id: snapshot.exposureSetId,
|
||||
agent_id: snapshot.agentId,
|
||||
run_id: snapshot.runId,
|
||||
run_id: snapshot.durableRunScopeId,
|
||||
context_fingerprint: snapshot.contextFingerprint,
|
||||
plan_id: snapshot.planId,
|
||||
revision_number: snapshot.revisionNumber,
|
||||
@@ -217,8 +215,8 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
|
||||
}
|
||||
const runId = getOwnedSessionTranscriptWriterFence()?.expectedWriterRunId;
|
||||
const exposure = runId
|
||||
? readMemoryRunExposure({
|
||||
agentId: params.database.agentId,
|
||||
? readDurableMemoryRunExposure({
|
||||
database: params.database,
|
||||
sessionId: params.sessionId,
|
||||
runId,
|
||||
})
|
||||
@@ -243,7 +241,7 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
|
||||
delivery_audiences_json: persisted.deliveryAudiencesJson,
|
||||
session_identity_revision: exposure.sessionIdentityRevision,
|
||||
subject_revision: exposure.subjectRevision,
|
||||
run_id: exposure.runId,
|
||||
run_id: exposure.durableRunScopeId,
|
||||
context_fingerprint: exposure.contextFingerprint,
|
||||
created_at: params.createdAt,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
|
||||
import { listSessionsNeedingTranscriptIndexReconcile } from "./session-transcript-index.js";
|
||||
import { isTranscriptMemoryPolicyEnforcedInDatabase } from "./session-transcript-memory-policy.js";
|
||||
import {
|
||||
isSessionTranscriptIndexReconcileRunning,
|
||||
startSessionTranscriptIndexReconcile,
|
||||
@@ -78,6 +79,31 @@ export function searchSessionTranscripts(params: {
|
||||
sessionKeys.length > 0
|
||||
? ` AND session_windows.session_key IN (${sessionKeys.map(() => "?").join(", ")})`
|
||||
: "";
|
||||
// FTS has its own raw query, but it shares replay and projection's P1C companion boundary.
|
||||
const whereAuthorizedTranscript = isTranscriptMemoryPolicyEnforcedInDatabase(database.db)
|
||||
? `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM transcript_event_memory_policies AS policy
|
||||
JOIN session_memory_subject_snapshots AS subject
|
||||
ON subject.session_id = policy.session_id
|
||||
JOIN memory_run_exposures AS exposure
|
||||
ON exposure.exposure_set_id = policy.run_exposure_set_id
|
||||
JOIN memory_policy_sets AS policy_set
|
||||
ON policy_set.policy_set_id = policy.source_policy_set_id
|
||||
WHERE policy.session_id = session_transcript_fts.session_id
|
||||
AND policy.event_seq = identity.seq
|
||||
AND policy.authorization_status = 'authorized'
|
||||
AND subject.session_identity_revision = policy.session_identity_revision
|
||||
AND subject.subject_revision = policy.subject_revision
|
||||
AND exposure.run_id = policy.run_id
|
||||
AND exposure.context_fingerprint = policy.context_fingerprint
|
||||
AND exposure.revision_number = policy.run_exposure_revision
|
||||
AND exposure.effective_source_policy_set_id = policy.source_policy_set_id
|
||||
AND exposure.delivery_audiences_json = policy.delivery_audiences_json
|
||||
AND policy_set.policy_set_id = exposure.effective_source_policy_set_id
|
||||
)`
|
||||
: "";
|
||||
// MATCH, snippet(), and bm25() are FTS5 primitives without a Kysely
|
||||
// representation. session_key lives on the window row so key renames
|
||||
// never leave stale keys inside the index. Sessions flagged needs_rebuild
|
||||
@@ -94,37 +120,7 @@ export function searchSessionTranscripts(params: {
|
||||
JOIN transcript_event_identities AS identity
|
||||
ON identity.session_id = session_transcript_fts.session_id
|
||||
AND identity.event_id = session_transcript_fts.message_id
|
||||
WHERE session_transcript_fts MATCH ?${whereSession}
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM memory_migrations AS migration
|
||||
WHERE migration.phase = 'cutover'
|
||||
AND migration.verified_at IS NOT NULL
|
||||
AND migration.cutover_at IS NOT NULL
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM transcript_event_memory_policies AS policy
|
||||
JOIN session_memory_subject_snapshots AS subject
|
||||
ON subject.session_id = policy.session_id
|
||||
JOIN memory_run_exposures AS exposure
|
||||
ON exposure.exposure_set_id = policy.run_exposure_set_id
|
||||
JOIN memory_policy_sets AS policy_set
|
||||
ON policy_set.policy_set_id = policy.source_policy_set_id
|
||||
WHERE policy.session_id = session_transcript_fts.session_id
|
||||
AND policy.event_seq = identity.seq
|
||||
AND policy.authorization_status = 'authorized'
|
||||
AND subject.session_identity_revision = policy.session_identity_revision
|
||||
AND subject.subject_revision = policy.subject_revision
|
||||
AND exposure.run_id = policy.run_id
|
||||
AND exposure.context_fingerprint = policy.context_fingerprint
|
||||
AND exposure.revision_number = policy.run_exposure_revision
|
||||
AND exposure.effective_source_policy_set_id = policy.source_policy_set_id
|
||||
AND exposure.delivery_audiences_json = policy.delivery_audiences_json
|
||||
AND policy_set.policy_set_id = exposure.effective_source_policy_set_id
|
||||
)
|
||||
)
|
||||
WHERE session_transcript_fts MATCH ?${whereSession}${whereAuthorizedTranscript}
|
||||
AND session_transcript_fts.session_id NOT IN (
|
||||
SELECT session_id FROM session_transcript_index_state WHERE needs_rebuild != 0
|
||||
)
|
||||
|
||||
@@ -256,6 +256,7 @@ export function buildMemorySystemPromptAddition(
|
||||
// its synchronous view, or an omitted flag would reopen those supplements.
|
||||
memoryReadEnforced:
|
||||
params.memoryReadEnforced ?? (prepared.context.memoryReadEnforced ? true : undefined),
|
||||
authorizedMemoryRead: params.authorizedMemoryRead ?? prepared.context.authorizedMemoryRead,
|
||||
};
|
||||
return renderMemorySystemPromptAddition(contextParams, prepared);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createInternalHookEvent } from "../../internal-hooks.js";
|
||||
|
||||
const memoryIsolationMocks = vi.hoisted(() => ({
|
||||
isMemoryIsolationCutoverAgent: vi.fn(() => false),
|
||||
isMemoryIsolationTranscriptPolicyEnforcedInDatabase: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/memory-cutover.js", () => memoryIsolationMocks);
|
||||
@@ -25,6 +26,9 @@ describe("session-memory automatic reset", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
memoryIsolationMocks.isMemoryIsolationCutoverAgent.mockReset().mockReturnValue(false);
|
||||
memoryIsolationMocks.isMemoryIsolationTranscriptPolicyEnforcedInDatabase
|
||||
.mockReset()
|
||||
.mockReturnValue(false);
|
||||
await flushSessionMemoryWritesForTest();
|
||||
});
|
||||
|
||||
|
||||
+4
-4
@@ -108,7 +108,7 @@ export const MEMORY_AUTHORIZATION_RUNTIME_AND_DELIVERY_PATH_ENTRIES = Object.fre
|
||||
"core-agent-runtime",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/agents/embedded-agent-runner/compaction-checkpoint.ts",
|
||||
"src/agents/main-session-restart-recovery-checkpoint.ts",
|
||||
"src/agents/main-session-recovery/main-session-restart-recovery-checkpoint.ts",
|
||||
"src/gateway/session-compaction-checkpoints.ts",
|
||||
),
|
||||
entry(
|
||||
@@ -193,7 +193,7 @@ export const MEMORY_AUTHORIZATION_RUNTIME_AND_DELIVERY_PATH_ENTRIES = Object.fre
|
||||
"egress",
|
||||
"core-agent-runtime",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/agents/subagent-registry.ts",
|
||||
"src/agents/subagents/registry/subagent-registry.ts",
|
||||
"src/agents/openclaw-tools.ts",
|
||||
),
|
||||
entry(
|
||||
@@ -201,7 +201,7 @@ export const MEMORY_AUTHORIZATION_RUNTIME_AND_DELIVERY_PATH_ENTRIES = Object.fre
|
||||
"egress",
|
||||
"core-agent-runtime",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/agents/subagent-announce-delivery.ts",
|
||||
"src/agents/subagents/announce/subagent-announce-delivery.ts",
|
||||
),
|
||||
entry(
|
||||
"cron-triggered-run",
|
||||
@@ -243,7 +243,7 @@ export const MEMORY_AUTHORIZATION_RUNTIME_AND_DELIVERY_PATH_ENTRIES = Object.fre
|
||||
"egress",
|
||||
"transport-egress-host",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/agents/tools/message-tool.ts",
|
||||
"src/agents/tools/message-tool-execution.ts",
|
||||
),
|
||||
entry(
|
||||
"session-send-delivery",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MEMORY_AUTHORIZATION_RUNTIME_AND_DELIVERY_PATH_ENTRIES } from "./memory-authorization-path-inventory.runtime-and-delivery.test-support.js";
|
||||
|
||||
/** Test-only Phase-0 inventory of every known path that can ingest, expose, or derive memory. */
|
||||
/** Test-only inventory of every known path that can ingest, expose, or derive memory. */
|
||||
export const MEMORY_AUTHORIZATION_PATH_DISPOSITIONS = [
|
||||
"authorized",
|
||||
"blocked-in-enforced-mode",
|
||||
@@ -40,9 +40,9 @@ function entry(
|
||||
}
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* Phase 1C routes only the selected memory search/get/session lanes through the opaque host.
|
||||
* Other legacy paths remain explicitly blocked in enforced mode or legacy-only until their owner
|
||||
* converts them; none may silently fall through to context-free access.
|
||||
*/
|
||||
export const MEMORY_AUTHORIZATION_PATH_INVENTORY = Object.freeze([
|
||||
entry(
|
||||
@@ -75,7 +75,7 @@ export const MEMORY_AUTHORIZATION_PATH_INVENTORY = Object.freeze([
|
||||
"bootstrap-memory-and-user-files",
|
||||
"egress",
|
||||
"core-agent-runtime",
|
||||
"legacy-only",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/agents/bootstrap-files.ts",
|
||||
"src/agents/workspace-bootstrap-read.ts",
|
||||
),
|
||||
@@ -83,31 +83,37 @@ export const MEMORY_AUTHORIZATION_PATH_INVENTORY = Object.freeze([
|
||||
"startup-recent-memory-context",
|
||||
"egress",
|
||||
"core-agent-runtime",
|
||||
"legacy-only",
|
||||
"blocked-in-enforced-mode",
|
||||
"src/auto-reply/reply/startup-context.ts",
|
||||
),
|
||||
entry(
|
||||
"memory-search-tool",
|
||||
"egress",
|
||||
"selected-memory-plugin",
|
||||
"legacy-only",
|
||||
"core-access-host",
|
||||
"authorized",
|
||||
"extensions/memory-core/src/tools.ts",
|
||||
"extensions/memory-core/src/tools.shared.ts",
|
||||
"src/agents/memory-authorized-read-host.ts",
|
||||
"src/plugins/memory-invocation.ts",
|
||||
),
|
||||
entry(
|
||||
"memory-get-tool",
|
||||
"egress",
|
||||
"selected-memory-plugin",
|
||||
"legacy-only",
|
||||
"core-access-host",
|
||||
"authorized",
|
||||
"extensions/memory-core/src/tools.ts",
|
||||
"src/agents/memory-authorized-read-host.ts",
|
||||
"src/plugins/memory-invocation.ts",
|
||||
),
|
||||
entry(
|
||||
"session-transcript-search",
|
||||
"egress",
|
||||
"selected-memory-plugin",
|
||||
"legacy-only",
|
||||
"core-access-host",
|
||||
"authorized",
|
||||
"extensions/memory-core/src/session-search-visibility.ts",
|
||||
"extensions/memory-core/src/tools.ts",
|
||||
"src/agents/memory-authorized-read-host.ts",
|
||||
"src/plugins/memory-invocation.ts",
|
||||
),
|
||||
entry(
|
||||
"active-memory-trigger-recall",
|
||||
|
||||
@@ -96,6 +96,14 @@ const REQUIRED_PHASE_0_PATH_IDS = [
|
||||
"plugin-and-mcp-outbound-actions",
|
||||
] as const;
|
||||
|
||||
const PHASE_1C_SELECTED_READ_PATHS = {
|
||||
"bootstrap-memory-and-user-files": "blocked-in-enforced-mode",
|
||||
"startup-recent-memory-context": "blocked-in-enforced-mode",
|
||||
"memory-search-tool": "authorized",
|
||||
"memory-get-tool": "authorized",
|
||||
"session-transcript-search": "authorized",
|
||||
} as const;
|
||||
|
||||
const MEMORY_MANAGER_CALL_NAMES = new Set([
|
||||
"getActiveMemorySearchManager",
|
||||
"getMemorySearchManager",
|
||||
@@ -481,8 +489,8 @@ describe("memory authorization path inventory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Phase 0 shadow-only and explicitly fails enforced bypasses closed", () => {
|
||||
expect(inventory.filter((item) => item.disposition === "authorized")).toEqual([]);
|
||||
it("keeps Phase 1C selected reads host-authorized and explicitly fails bypasses closed", () => {
|
||||
expect(inventory.some((item) => item.disposition === "authorized")).toBe(true);
|
||||
expect(inventory.filter((item) => item.disposition === "operator-only-authenticated")).toEqual(
|
||||
[],
|
||||
);
|
||||
@@ -490,6 +498,23 @@ describe("memory authorization path inventory", () => {
|
||||
expect(inventory.some((item) => item.disposition === "blocked-in-enforced-mode")).toBe(true);
|
||||
});
|
||||
|
||||
it("records the exact Phase 1C selected-read and legacy-bypass dispositions", () => {
|
||||
const entriesById = new Map(inventory.map((item) => [item.id, item]));
|
||||
|
||||
for (const [id, disposition] of Object.entries(PHASE_1C_SELECTED_READ_PATHS)) {
|
||||
expect(entriesById.get(id)).toMatchObject({ disposition });
|
||||
}
|
||||
for (const id of ["memory-search-tool", "memory-get-tool", "session-transcript-search"]) {
|
||||
expect(entriesById.get(id)).toMatchObject({
|
||||
owner: "core-access-host",
|
||||
surfaces: expect.arrayContaining([
|
||||
"src/agents/memory-authorized-read-host.ts",
|
||||
"src/plugins/memory-invocation.ts",
|
||||
]),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { referenceMemoryAuthorizationConformanceAdapter } from "../memory-host-sdk/host/authorization-conformance.js";
|
||||
import { referenceMemoryAuthorizationConformanceAdapter } from "../plugin-sdk/memory-authorization-conformance.js";
|
||||
import {
|
||||
COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
} from "../plugin-sdk/memory-authorization.js";
|
||||
import {
|
||||
admitMemoryAuthorizationReadRuntime,
|
||||
inspectMemoryAuthorizationCapability,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
runMemoryAuthorizationConformanceSuite,
|
||||
type MemoryAuthorizationConformanceAdapter,
|
||||
} from "../memory-host-sdk/host/authorization-conformance.js";
|
||||
} from "../plugin-sdk/memory-authorization-conformance.js";
|
||||
import {
|
||||
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
|
||||
isMemoryAuthorizationCapabilities,
|
||||
listMissingMemoryAuthorizationCapabilities,
|
||||
type AuthorizedMemoryRuntime,
|
||||
type MemoryAuthorizationCapabilityName,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
} from "../plugin-sdk/memory-authorization.js";
|
||||
|
||||
const AUTHORIZED_MEMORY_RUNTIME_METHODS = [
|
||||
"authorize",
|
||||
|
||||
@@ -41,6 +41,11 @@ describe("memory isolation lifecycle", () => {
|
||||
.run(params.sessionKey ?? "agent:main:pilot", params.principalId, randomUUID());
|
||||
}
|
||||
|
||||
it("does not borrow an agent cutover state when the caller has no agent scope", () => {
|
||||
expect(isMemoryIsolationCutoverAgent("")).toBe(false);
|
||||
expect(isMemoryIsolationCutoverAgent(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("persists a verified shadow-read-only marker and activates it only after a cache reset", () => {
|
||||
insertPilotSubject({ principalId: "principal-alice" });
|
||||
expect(resolveMemoryIsolationMode("main")).toBe("legacy");
|
||||
|
||||
@@ -370,7 +370,10 @@ export function isMemoryIsolationSubjectAdmitted(params: {
|
||||
* memory. An unreadable authority store fails closed: selected-memory callers never use legacy.
|
||||
*/
|
||||
export function isMemoryIsolationCutoverAgent(agentIdInput: string): boolean {
|
||||
return resolveMemoryIsolationMode(agentIdInput) !== "legacy";
|
||||
const agentId = agentIdInput.trim();
|
||||
// Tool construction can be intentionally unscoped (for example, local workspace utilities).
|
||||
// It has no authority-store owner, so it must retain legacy behavior rather than borrow an agent.
|
||||
return agentId ? resolveMemoryIsolationMode(agentId) !== "legacy" : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,20 +3,21 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAuthorizedMemoryReadHost } from "../agents/memory-authorized-read-host.js";
|
||||
import { referenceMemoryAuthorizationConformanceAdapter } from "../memory-host-sdk/host/authorization-conformance.js";
|
||||
import { referenceMemoryAuthorizationConformanceAdapter } from "../plugin-sdk/memory-authorization-conformance.js";
|
||||
import {
|
||||
COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
type AuthorizedMemoryPlan,
|
||||
type AuthorizedMemorySearchResult,
|
||||
type MemoryContentAccessContext,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
} from "../plugin-sdk/memory-authorization.js";
|
||||
import { ensureMemoryOperationalPrincipal } from "../state/memory-identity.js";
|
||||
import { persistMemorySessionSubject } from "../state/memory-session-subject.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { ensureOpenClawAgentScopedMemorySchema } from "../state/openclaw-agent-scoped-memory-schema.js";
|
||||
import { resetMemoryIsolationCutoverForTest } from "./memory-cutover.js";
|
||||
import { MEMORY_INVOCATION_UNAVAILABLE } from "./memory-invocation.js";
|
||||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
@@ -56,6 +57,7 @@ function createAuthorizedReadHost() {
|
||||
const sessionId = "memory-invocation-session";
|
||||
const options = { agentId, env };
|
||||
const database = openOpenClawAgentDatabase(options);
|
||||
ensureOpenClawAgentScopedMemorySchema(database.db);
|
||||
database.db
|
||||
.prepare(
|
||||
"INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, '{}', 1)",
|
||||
@@ -97,7 +99,7 @@ function createAuthorizedReadHost() {
|
||||
if (!host) {
|
||||
throw new Error("failed to create authorized memory read host");
|
||||
}
|
||||
return host;
|
||||
return { database, host };
|
||||
}
|
||||
|
||||
function registerSelectedCapability(capability: unknown) {
|
||||
@@ -111,7 +113,7 @@ function registerSelectedCapability(capability: unknown) {
|
||||
}
|
||||
|
||||
function createRuntime(params: {
|
||||
searchAuthorized?: () => Promise<unknown>;
|
||||
searchAuthorized?: (context: MemoryContentAccessContext<"read">) => Promise<unknown>;
|
||||
authorize?: (context: MemoryContentAccessContext<"read">) => Promise<AuthorizedMemoryPlan>;
|
||||
}) {
|
||||
const legacySearch = vi.fn();
|
||||
@@ -120,7 +122,8 @@ function createRuntime(params: {
|
||||
runtime: {
|
||||
authorize: async (context: MemoryContentAccessContext<"read">) =>
|
||||
await (params.authorize?.(context) ?? Promise.resolve(createPlan(context))),
|
||||
searchAuthorized: async () => await params.searchAuthorized?.(),
|
||||
searchAuthorized: async ({ context }: { context: MemoryContentAccessContext<"read"> }) =>
|
||||
await params.searchAuthorized?.(context),
|
||||
readAuthorized: async () => {
|
||||
throw new Error("exact read must not execute in this test");
|
||||
},
|
||||
@@ -191,7 +194,7 @@ describe("enforced selected-memory invocation failures", () => {
|
||||
authorizationConformance: referenceMemoryAuthorizationConformanceAdapter,
|
||||
runtime,
|
||||
});
|
||||
const result = await createAuthorizedReadHost().search({ query: "private" });
|
||||
const result = await createAuthorizedReadHost().host.search({ query: "private" });
|
||||
|
||||
expect(result).toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(JSON.stringify(result)).not.toContain("private legacy content");
|
||||
@@ -220,9 +223,95 @@ describe("enforced selected-memory invocation failures", () => {
|
||||
}
|
||||
registerSelectedCapability(capability);
|
||||
|
||||
await expect(createAuthorizedReadHost().search({ query: "private" })).resolves.toBe(
|
||||
await expect(createAuthorizedReadHost().host.search({ query: "private" })).resolves.toBe(
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
);
|
||||
expect(legacySearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits the content-free exposure ledger before returning a selected-plugin result", async () => {
|
||||
const { runtime } = createRuntime({
|
||||
searchAuthorized: async (context) => ({
|
||||
version: 1,
|
||||
value: [
|
||||
{
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "allowed text",
|
||||
source: "memory",
|
||||
resourceHandle: {
|
||||
version: 1,
|
||||
handleId: "handle-1",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
resourceRevision: "revision-1",
|
||||
policyRevision: "policy-1",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
exposureReceipt: {
|
||||
version: 1,
|
||||
receiptId: "exposure-1",
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
planId: "plan-1",
|
||||
runId: context.runId,
|
||||
runExposureRevision: "run-exposure-1",
|
||||
sourcePolicySetId: "source-policy-1",
|
||||
exposedRevisionHandles: ["revision-1"],
|
||||
recordedAt: new Date().toISOString(),
|
||||
},
|
||||
egressReceipt: {
|
||||
version: 1,
|
||||
receiptId: "egress-1",
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
planId: "plan-1",
|
||||
runId: context.runId,
|
||||
runExposureRevision: "run-exposure-1",
|
||||
sourcePolicySetId: "source-policy-1",
|
||||
allowedAudiences: context.delivery.audiences,
|
||||
deliveryRevision: context.delivery.deliveryRevision,
|
||||
egressRegistryRevision: context.delivery.egressRegistryRevision,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
registerSelectedCapability({
|
||||
authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
authorizationConformance: referenceMemoryAuthorizationConformanceAdapter,
|
||||
runtime,
|
||||
});
|
||||
const { database, host } = createAuthorizedReadHost();
|
||||
|
||||
await expect(host.search({ query: "private" })).resolves.toEqual({
|
||||
results: [
|
||||
{
|
||||
handleId: "handle-1",
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "allowed text",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
database.db
|
||||
.prepare(
|
||||
`SELECT session_id, run_id, revision_number, exposure_receipt_ids_json
|
||||
FROM memory_preoutput_exposure_ledger`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
session_id: "memory-invocation-session",
|
||||
run_id: "run-1",
|
||||
revision_number: 1,
|
||||
exposure_receipt_ids_json: '["exposure-1"]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
|
||||
type AuthorizedMemoryPlan,
|
||||
type AuthorizedMemoryResultEnvelope,
|
||||
type AuthorizedMemorySearchResult,
|
||||
type MemoryAccessContext,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
} from "../plugin-sdk/memory-authorization.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
admit: vi.fn(),
|
||||
materialize: vi.fn(),
|
||||
hydrateExposure: vi.fn(() => true),
|
||||
logWarn: vi.fn(),
|
||||
persistExposure: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../state/memory-access-context.js", async (importOriginal) => ({
|
||||
@@ -21,12 +24,23 @@ vi.mock("./memory-authorization-runtime.js", () => ({
|
||||
admitMemoryAuthorizationReadRuntime: mocks.admit,
|
||||
}));
|
||||
|
||||
vi.mock("./memory-run-exposure-ledger.js", () => ({
|
||||
hydrateMemoryRunExposureFromLedger: mocks.hydrateExposure,
|
||||
persistMemoryRunExposureBeforeContent: mocks.persistExposure,
|
||||
}));
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
logWarn: mocks.logWarn,
|
||||
}));
|
||||
|
||||
const {
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
createAuthorizedMemoryReadInvocation,
|
||||
readAuthorizedMemoryForInvocation,
|
||||
readAuthorizedMemoryRunExposure,
|
||||
searchAuthorizedMemoryForInvocation,
|
||||
} = await import("./memory-invocation.js");
|
||||
const { clearMemoryRunExposureForTest } = await import("./memory-run-exposure.js");
|
||||
|
||||
function createContext(): MemoryAccessContext & Readonly<{ operation: "read" }> {
|
||||
return {
|
||||
@@ -133,6 +147,16 @@ function createEnvelope<T>(
|
||||
}
|
||||
|
||||
describe("authorized memory read invocation", () => {
|
||||
afterEach(() => {
|
||||
clearMemoryRunExposureForTest();
|
||||
receiptSequence = 0;
|
||||
mocks.hydrateExposure.mockReset();
|
||||
mocks.hydrateExposure.mockReturnValue(true);
|
||||
mocks.logWarn.mockReset();
|
||||
mocks.persistExposure.mockReset();
|
||||
mocks.persistExposure.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns only an unavailable result when backend admission fails", async () => {
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: false, reasonCode: "backend-nonconforming" });
|
||||
@@ -145,6 +169,29 @@ describe("authorized memory read invocation", () => {
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("emits fixed diagnostics without memory access facts or backend error content", async () => {
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({
|
||||
ok: true,
|
||||
runtime: {
|
||||
authorize: vi.fn().mockRejectedValue(new Error("private memory error")),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
}),
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
|
||||
expect(mocks.logWarn).toHaveBeenCalledWith(
|
||||
"memory invocation unavailable: authorization-failed",
|
||||
);
|
||||
expect(JSON.stringify(mocks.logWarn.mock.calls)).not.toContain("private memory error");
|
||||
expect(JSON.stringify(mocks.logWarn.mock.calls)).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("does not leak a search result unless its current exposure and egress receipts validate", async () => {
|
||||
const handle = {
|
||||
version: 1 as const,
|
||||
@@ -217,6 +264,236 @@ describe("authorized memory read invocation", () => {
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("does not return selected-plugin content or commit a receipt when exposure recording fails", async () => {
|
||||
const result: AuthorizedMemorySearchResult = {
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "private content",
|
||||
source: "memory",
|
||||
resourceHandle: {
|
||||
version: 1,
|
||||
handleId: "handle-1",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
resourceRevision: "revision-1",
|
||||
policyRevision: "policy-1",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(createPlan()),
|
||||
searchAuthorized: vi.fn().mockResolvedValue(createEnvelope([result])),
|
||||
readAuthorized: vi.fn(),
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
mocks.persistExposure.mockReturnValue(false);
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
});
|
||||
expect(invocation).not.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await searchAuthorizedMemoryForInvocation({ invocation, query: "private" });
|
||||
|
||||
expect(response).toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(JSON.stringify(response)).not.toContain("private content");
|
||||
expect(mocks.persistExposure).toHaveBeenCalledOnce();
|
||||
expect(readAuthorizedMemoryRunExposure(invocation)).toEqual({
|
||||
sourcePolicySetIds: [],
|
||||
exposedRevisionHandles: [],
|
||||
exposureReceiptIds: [],
|
||||
egressReceiptIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return exact-read text or commit its receipt when the ledger write fails", async () => {
|
||||
const handle = {
|
||||
version: 1 as const,
|
||||
handleId: "handle-1",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
resourceRevision: "revision-1",
|
||||
policyRevision: "policy-1",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(createPlan()),
|
||||
searchAuthorized: vi.fn().mockResolvedValue(
|
||||
createEnvelope([
|
||||
{
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "search text",
|
||||
source: "memory",
|
||||
resourceHandle: handle,
|
||||
},
|
||||
]),
|
||||
),
|
||||
readAuthorized: vi
|
||||
.fn()
|
||||
.mockResolvedValue(createEnvelope({ path: "private/note.md", text: "private exact text" })),
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
});
|
||||
expect(invocation).not.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
searchAuthorizedMemoryForInvocation({ invocation, query: "private" }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({ results: [expect.objectContaining({ handleId: "handle-1" })] }),
|
||||
);
|
||||
mocks.persistExposure.mockReturnValue(false);
|
||||
|
||||
const response = await readAuthorizedMemoryForInvocation({ invocation, handleId: "handle-1" });
|
||||
|
||||
expect(response).toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(JSON.stringify(response)).not.toContain("private exact text");
|
||||
expect(readAuthorizedMemoryRunExposure(invocation)).toEqual({
|
||||
sourcePolicySetIds: ["policy-set-1"],
|
||||
exposedRevisionHandles: ["revision-1"],
|
||||
exposureReceiptIds: ["exposure-1"],
|
||||
egressReceiptIds: ["egress-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a fresh exact-read receipt after a failed ledger write", async () => {
|
||||
const handle = {
|
||||
version: 1 as const,
|
||||
handleId: "handle-1",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
resourceRevision: "revision-1",
|
||||
policyRevision: "policy-1",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(createPlan()),
|
||||
searchAuthorized: vi.fn().mockResolvedValue(
|
||||
createEnvelope([
|
||||
{
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "search text",
|
||||
source: "memory",
|
||||
resourceHandle: handle,
|
||||
},
|
||||
]),
|
||||
),
|
||||
readAuthorized: vi
|
||||
.fn()
|
||||
.mockImplementation(async () =>
|
||||
createEnvelope({ path: "private/note.md", text: "private exact text" }),
|
||||
),
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
});
|
||||
expect(invocation).not.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
searchAuthorizedMemoryForInvocation({ invocation, query: "private" }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({ results: [expect.objectContaining({ handleId: "handle-1" })] }),
|
||||
);
|
||||
mocks.persistExposure.mockReturnValueOnce(false).mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
readAuthorizedMemoryForInvocation({ invocation, handleId: "handle-1" }),
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
await expect(
|
||||
readAuthorizedMemoryForInvocation({ invocation, handleId: "handle-1" }),
|
||||
).resolves.toEqual({ path: "private/note.md", text: "private exact text" });
|
||||
expect(readAuthorizedMemoryRunExposure(invocation)).toEqual({
|
||||
sourcePolicySetIds: ["policy-set-1"],
|
||||
exposedRevisionHandles: ["revision-1"],
|
||||
exposureReceiptIds: ["exposure-1", "exposure-3"],
|
||||
egressReceiptIds: ["egress-1", "egress-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a fresh receipt after a failed ledger write without retaining the failed attempt", async () => {
|
||||
const result = (snippet: string, handleId: string): AuthorizedMemorySearchResult => ({
|
||||
path: "private/note.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet,
|
||||
source: "memory",
|
||||
resourceHandle: {
|
||||
version: 1,
|
||||
handleId,
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
resourceRevision: "revision-1",
|
||||
policyRevision: "policy-1",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
});
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(createPlan()),
|
||||
searchAuthorized: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createEnvelope([result("first private text", "handle-1")]))
|
||||
.mockResolvedValueOnce(createEnvelope([result("retry text", "handle-2")])),
|
||||
readAuthorized: vi.fn(),
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
mocks.persistExposure.mockReturnValueOnce(false).mockReturnValue(true);
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
});
|
||||
expect(invocation).not.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
await expect(searchAuthorizedMemoryForInvocation({ invocation, query: "first" })).resolves.toBe(
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
);
|
||||
await expect(
|
||||
searchAuthorizedMemoryForInvocation({ invocation, query: "retry" }),
|
||||
).resolves.toEqual({
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
handleId: "handle-2",
|
||||
snippet: "retry text",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(readAuthorizedMemoryRunExposure(invocation)).toEqual({
|
||||
sourcePolicySetIds: ["policy-set-1"],
|
||||
exposedRevisionHandles: ["revision-1"],
|
||||
exposureReceiptIds: ["exposure-2"],
|
||||
egressReceiptIds: ["egress-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "has an invalid timestamp",
|
||||
@@ -250,6 +527,38 @@ describe("authorized memory read invocation", () => {
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("does not leak a search result from an unsupported result-envelope version", async () => {
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(createPlan()),
|
||||
searchAuthorized: vi.fn().mockResolvedValue({
|
||||
...createEnvelope([]),
|
||||
version: 2,
|
||||
}),
|
||||
readAuthorized: vi.fn(),
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({
|
||||
context: {} as never,
|
||||
capability: { authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES },
|
||||
});
|
||||
expect(invocation).not.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(
|
||||
searchAuthorizedMemoryForInvocation({ invocation, query: "private" }),
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(readAuthorizedMemoryRunExposure(invocation)).toEqual({
|
||||
sourcePolicySetIds: [],
|
||||
exposedRevisionHandles: [],
|
||||
exposureReceiptIds: [],
|
||||
egressReceiptIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a replayed exposure receipt instead of exposing the repeated result", async () => {
|
||||
const handle = {
|
||||
version: 1 as const,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logWarn } from "../logger.js";
|
||||
import type {
|
||||
AuthorizedMemoryPlan,
|
||||
AuthorizedMemoryResultEnvelope,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
admitMemoryAuthorizationReadRuntime,
|
||||
type AdmittedAuthorizedMemoryReadRuntime,
|
||||
} from "./memory-authorization-runtime.js";
|
||||
import {
|
||||
hydrateMemoryRunExposureFromLedger,
|
||||
persistMemoryRunExposureBeforeContent,
|
||||
} from "./memory-run-exposure-ledger.js";
|
||||
import { prepareMemoryRunExposure, publishMemoryRunExposure } from "./memory-run-exposure.js";
|
||||
import { resolveSelectedMemoryCapabilityRegistration } from "./memory-state.js";
|
||||
import type { MemoryPluginCapability } from "./registry-contribution-types.js";
|
||||
import { requireActivePluginRegistry } from "./runtime.js";
|
||||
@@ -56,6 +62,19 @@ type InvocationState = Readonly<{
|
||||
|
||||
const invocationStates = new WeakMap<object, InvocationState>();
|
||||
|
||||
type MemoryInvocationDiagnostic =
|
||||
| "admission-rejected"
|
||||
| "authorization-failed"
|
||||
| "invalid-plan"
|
||||
| "materialization-rejected"
|
||||
| "search-failed";
|
||||
|
||||
function logMemoryInvocationDiagnostic(diagnostic: MemoryInvocationDiagnostic): void {
|
||||
// Memory content, access facts, capability metadata, plans, and backend errors are all sensitive.
|
||||
// Keep the emitted diagnostic low-cardinality and content-free; the unavailable result is intentional.
|
||||
logWarn(`memory invocation unavailable: ${diagnostic}`);
|
||||
}
|
||||
|
||||
function sameAudiences(left: readonly AudienceRef[], right: readonly AudienceRef[]): boolean {
|
||||
const key = (audience: AudienceRef) => `${audience.kind}\u0000${audience.id}`;
|
||||
const leftKeys = [...new Set(left.map(key))].toSorted();
|
||||
@@ -112,7 +131,7 @@ function readCurrentContext(
|
||||
return readContext;
|
||||
}
|
||||
|
||||
function mergeAndValidateEnvelope<T>(params: {
|
||||
function validateEnvelope<T>(params: {
|
||||
state: InvocationState;
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
expectedRevisionHandles: readonly string[];
|
||||
@@ -124,6 +143,7 @@ function mergeAndValidateEnvelope<T>(params: {
|
||||
const exposureRecordedAt = Date.parse(exposureReceipt.recordedAt);
|
||||
const egressExpiry = Date.parse(egressReceipt.expiresAt);
|
||||
if (
|
||||
envelope.version !== 1 ||
|
||||
exposureReceipt.version !== 1 ||
|
||||
egressReceipt.version !== 1 ||
|
||||
exposureReceipt.contextFingerprint !== context.contextFingerprint ||
|
||||
@@ -161,6 +181,14 @@ function mergeAndValidateEnvelope<T>(params: {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeEnvelope(
|
||||
state: InvocationState,
|
||||
envelope: AuthorizedMemoryResultEnvelope<unknown>,
|
||||
): void {
|
||||
const { exposureReceipt, egressReceipt } = envelope;
|
||||
state.sourcePolicySetIds.add(exposureReceipt.sourcePolicySetId);
|
||||
for (const revision of exposureReceipt.exposedRevisionHandles) {
|
||||
state.exposedRevisionHandles.add(revision);
|
||||
@@ -168,7 +196,76 @@ function mergeAndValidateEnvelope<T>(params: {
|
||||
state.exposureReceiptIds.add(exposureReceipt.receiptId);
|
||||
state.egressReceiptIds.add(egressReceipt.receiptId);
|
||||
state.runExposureRevisions.add(exposureReceipt.runExposureRevision);
|
||||
return true;
|
||||
}
|
||||
|
||||
function readTranscriptExposure(params: {
|
||||
state: InvocationState;
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
pendingEnvelope?: AuthorizedMemoryResultEnvelope<unknown>;
|
||||
}) {
|
||||
const { state, context, pendingEnvelope } = params;
|
||||
const sourcePolicySetIds = new Set(state.sourcePolicySetIds);
|
||||
const exposedResourceRevisions = new Set(state.exposedRevisionHandles);
|
||||
const exposureReceiptIds = new Set(state.exposureReceiptIds);
|
||||
const egressReceiptIds = new Set(state.egressReceiptIds);
|
||||
if (pendingEnvelope) {
|
||||
const { exposureReceipt, egressReceipt } = pendingEnvelope;
|
||||
sourcePolicySetIds.add(exposureReceipt.sourcePolicySetId);
|
||||
for (const revision of exposureReceipt.exposedRevisionHandles) {
|
||||
exposedResourceRevisions.add(revision);
|
||||
}
|
||||
exposureReceiptIds.add(exposureReceipt.receiptId);
|
||||
egressReceiptIds.add(egressReceipt.receiptId);
|
||||
}
|
||||
return Object.freeze({
|
||||
agentId: context.agentId,
|
||||
sessionId: context.sessionId,
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
planId: state.plan.planId,
|
||||
memoryPolicyRevision: state.plan.memoryPolicyRevision,
|
||||
sourcePolicySetIds: Object.freeze([...sourcePolicySetIds].toSorted()),
|
||||
exposedResourceRevisions: Object.freeze([...exposedResourceRevisions].toSorted()),
|
||||
exposureReceiptIds: Object.freeze([...exposureReceiptIds].toSorted()),
|
||||
egressReceiptIds: Object.freeze([...egressReceiptIds].toSorted()),
|
||||
deliveryAudiences: Object.freeze([...context.delivery.audiences]),
|
||||
deliveryRevision: context.delivery.deliveryRevision,
|
||||
egressRegistryRevision: context.delivery.egressRegistryRevision,
|
||||
sessionIdentityRevision: context.sessionIdentityRevision,
|
||||
subjectRevision: context.subjectRevision,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the next immutable exposure revision before the selected plugin's content can leave
|
||||
* this broker. A recording failure leaves the invocation state unchanged and fails the read closed.
|
||||
*/
|
||||
function recordEnvelopeExposure(params: {
|
||||
state: InvocationState;
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
envelope: AuthorizedMemoryResultEnvelope<unknown>;
|
||||
}): void {
|
||||
if (
|
||||
!hydrateMemoryRunExposureFromLedger({
|
||||
agentId: params.context.agentId,
|
||||
sessionId: params.context.sessionId,
|
||||
runId: params.context.runId,
|
||||
})
|
||||
) {
|
||||
throw new Error("memory exposure ledger could not restore its durable tail");
|
||||
}
|
||||
const snapshot = prepareMemoryRunExposure(
|
||||
readTranscriptExposure({
|
||||
state: params.state,
|
||||
context: params.context,
|
||||
pendingEnvelope: params.envelope,
|
||||
}),
|
||||
);
|
||||
if (!persistMemoryRunExposureBeforeContent(snapshot) || !publishMemoryRunExposure(snapshot)) {
|
||||
throw new Error("memory exposure ledger did not commit before content release");
|
||||
}
|
||||
mergeEnvelope(params.state, params.envelope);
|
||||
}
|
||||
|
||||
function readState(invocation: AuthorizedMemoryReadInvocation): InvocationState | undefined {
|
||||
@@ -185,6 +282,7 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
}): Promise<AuthorizedMemoryReadInvocation | MemoryInvocationUnavailable> {
|
||||
const materialized = materializeTrustedMemoryAccessContext(params.context);
|
||||
if (!materialized || materialized.operation !== "read") {
|
||||
logMemoryInvocationDiagnostic("materialization-rejected");
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
const context = materialized as MemoryContentAccessContext<"read">;
|
||||
@@ -193,6 +291,7 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
resolveSelectedMemoryCapabilityRegistration(requireActivePluginRegistry())?.capability;
|
||||
const admission = await admitMemoryAuthorizationReadRuntime(capability);
|
||||
if (!admission.ok) {
|
||||
logMemoryInvocationDiagnostic("admission-rejected");
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
try {
|
||||
@@ -200,6 +299,7 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
const plan = (await admission.runtime.authorize(context)) as AuthorizedMemoryPlan &
|
||||
Readonly<{ operation: "read" }>;
|
||||
if (!isCurrentPlan({ context, plan, nowMs: Date.now() })) {
|
||||
logMemoryInvocationDiagnostic("invalid-plan");
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
const invocation = Object.freeze({}) as AuthorizedMemoryReadInvocation;
|
||||
@@ -221,6 +321,7 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
);
|
||||
return invocation;
|
||||
} catch {
|
||||
logMemoryInvocationDiagnostic("authorization-failed");
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
@@ -253,7 +354,7 @@ export async function searchAuthorizedMemoryForInvocation(params: {
|
||||
});
|
||||
const revisionHandles = envelope.value.map((result) => result.resourceHandle.resourceRevision);
|
||||
if (
|
||||
!mergeAndValidateEnvelope({
|
||||
!validateEnvelope({
|
||||
state,
|
||||
context,
|
||||
expectedRevisionHandles: revisionHandles,
|
||||
@@ -262,6 +363,7 @@ export async function searchAuthorizedMemoryForInvocation(params: {
|
||||
) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
recordEnvelopeExposure({ state, context, envelope });
|
||||
const results = envelope.value.map((result) => {
|
||||
state.handles.set(result.resourceHandle.handleId, result.resourceHandle);
|
||||
const { resourceHandle: _resourceHandle, ...safe } = result;
|
||||
@@ -269,6 +371,7 @@ export async function searchAuthorizedMemoryForInvocation(params: {
|
||||
});
|
||||
return Object.freeze({ results: Object.freeze(results) });
|
||||
} catch {
|
||||
logMemoryInvocationDiagnostic("search-failed");
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
@@ -299,7 +402,7 @@ export async function readAuthorizedMemoryForInvocation(params: {
|
||||
...(params.lines !== undefined ? { lines: params.lines } : {}),
|
||||
});
|
||||
if (
|
||||
!mergeAndValidateEnvelope({
|
||||
!validateEnvelope({
|
||||
state,
|
||||
context,
|
||||
expectedRevisionHandles: [handle.resourceRevision],
|
||||
@@ -308,6 +411,7 @@ export async function readAuthorizedMemoryForInvocation(params: {
|
||||
) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
recordEnvelopeExposure({ state, context, envelope });
|
||||
return Object.freeze({ ...envelope.value });
|
||||
} catch {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
@@ -361,22 +465,5 @@ export function readAuthorizedMemoryTranscriptExposure(invocation: AuthorizedMem
|
||||
if (!state || !context || !isCurrentPlan({ context, plan: state.plan, nowMs: Date.now() })) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
agentId: context.agentId,
|
||||
sessionId: context.sessionId,
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
planId: state.plan.planId,
|
||||
memoryPolicyRevision: state.plan.memoryPolicyRevision,
|
||||
sourcePolicySetIds: Object.freeze([...state.sourcePolicySetIds].toSorted()),
|
||||
exposedResourceRevisions: Object.freeze([...state.exposedRevisionHandles].toSorted()),
|
||||
exposureReceiptIds: Object.freeze([...state.exposureReceiptIds].toSorted()),
|
||||
egressReceiptIds: Object.freeze([...state.egressReceiptIds].toSorted()),
|
||||
deliveryAudiences: Object.freeze([...context.delivery.audiences]),
|
||||
deliveryRevision: context.delivery.deliveryRevision,
|
||||
egressRegistryRevision: context.delivery.egressRegistryRevision,
|
||||
sessionIdentityRevision: context.sessionIdentityRevision,
|
||||
subjectRevision: context.subjectRevision,
|
||||
});
|
||||
return readTranscriptExposure({ state, context });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: undefined as
|
||||
| {
|
||||
agentId: string;
|
||||
db: DatabaseSync;
|
||||
path: string;
|
||||
walMaintenance: never;
|
||||
}
|
||||
| undefined,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../state/openclaw-agent-db.js", () => ({
|
||||
openOpenClawAgentDatabase: () => {
|
||||
if (!mocks.database) {
|
||||
throw new Error("test database is unavailable");
|
||||
}
|
||||
return mocks.database;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
logWarn: mocks.logWarn,
|
||||
}));
|
||||
|
||||
const {
|
||||
hydrateMemoryRunExposureFromLedger,
|
||||
persistMemoryRunExposureBeforeContent,
|
||||
readDurableMemoryRunExposure,
|
||||
} = await import("./memory-run-exposure-ledger.js");
|
||||
const { clearMemoryRunExposureForTest, prepareMemoryRunExposure } =
|
||||
await import("./memory-run-exposure.js");
|
||||
|
||||
let database: DatabaseSync | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
database = new DatabaseSync(":memory:");
|
||||
mocks.database = {
|
||||
agentId: "main",
|
||||
db: database,
|
||||
path: ":memory:",
|
||||
walMaintenance: undefined as never,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMemoryRunExposureForTest();
|
||||
mocks.database = undefined;
|
||||
database?.close();
|
||||
database = undefined;
|
||||
});
|
||||
|
||||
function prepare(sessionId: string) {
|
||||
return prepareMemoryRunExposure({
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey: `agent:main:direct:${sessionId}`,
|
||||
runId: "shared-run-id",
|
||||
contextFingerprint: `fingerprint:${sessionId}`,
|
||||
planId: `plan:${sessionId}`,
|
||||
memoryPolicyRevision: "policy-1",
|
||||
sourcePolicySetIds: ["source-policy-1"],
|
||||
exposedResourceRevisions: ["revision-1"],
|
||||
exposureReceiptIds: ["exposure-1"],
|
||||
egressReceiptIds: ["egress-1"],
|
||||
deliveryAudiences: [{ kind: "user", id: "alice" }],
|
||||
deliveryRevision: "delivery-1",
|
||||
egressRegistryRevision: "egress-1",
|
||||
sessionIdentityRevision: "identity-1",
|
||||
subjectRevision: "subject-1",
|
||||
});
|
||||
}
|
||||
|
||||
describe("memory pre-output exposure ledger", () => {
|
||||
it("commits content-free rows before publication and separates the same raw run across sessions", () => {
|
||||
const first = prepare("session-a");
|
||||
const second = prepare("session-b");
|
||||
|
||||
expect(persistMemoryRunExposureBeforeContent(first)).toBe(true);
|
||||
expect(persistMemoryRunExposureBeforeContent(second)).toBe(true);
|
||||
expect(first.durableRunScopeId).not.toBe(second.durableRunScopeId);
|
||||
|
||||
expect(
|
||||
database
|
||||
?.prepare(
|
||||
`SELECT session_id, run_id, revision_number, exposure_set_id,
|
||||
source_policy_set_ids_json, exposed_resource_revisions_json
|
||||
FROM memory_preoutput_exposure_ledger
|
||||
ORDER BY session_id`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
session_id: "session-a",
|
||||
run_id: "shared-run-id",
|
||||
revision_number: 1,
|
||||
exposure_set_id: first.exposureSetId,
|
||||
source_policy_set_ids_json: '["source-policy-1"]',
|
||||
exposed_resource_revisions_json: '["revision-1"]',
|
||||
},
|
||||
{
|
||||
session_id: "session-b",
|
||||
run_id: "shared-run-id",
|
||||
revision_number: 1,
|
||||
exposure_set_id: second.exposureSetId,
|
||||
source_policy_set_ids_json: '["source-policy-1"]',
|
||||
exposed_resource_revisions_json: '["revision-1"]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed on a duplicate revision without adding a partial row", () => {
|
||||
const snapshot = prepare("session-a");
|
||||
|
||||
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true);
|
||||
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(false);
|
||||
expect(
|
||||
database?.prepare("SELECT count(*) AS count FROM memory_preoutput_exposure_ledger").get(),
|
||||
).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
it("emits a fixed diagnostic without a caught database error", () => {
|
||||
const snapshot = prepare("session-a");
|
||||
mocks.database = undefined;
|
||||
|
||||
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(false);
|
||||
|
||||
expect(mocks.logWarn).toHaveBeenCalledWith(
|
||||
"memory exposure ledger unavailable: persist-failed",
|
||||
);
|
||||
expect(JSON.stringify(mocks.logWarn.mock.calls)).not.toContain("test database is unavailable");
|
||||
});
|
||||
|
||||
it("rehydrates a durable tail after restart before advancing the same run", () => {
|
||||
const first = prepare("session-a");
|
||||
expect(persistMemoryRunExposureBeforeContent(first)).toBe(true);
|
||||
|
||||
clearMemoryRunExposureForTest();
|
||||
expect(
|
||||
hydrateMemoryRunExposureFromLedger({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toBe(true);
|
||||
const second = prepare("session-a");
|
||||
expect(second.revisionNumber).toBe(2);
|
||||
expect(second.previous?.exposureSetId).toBe(first.exposureSetId);
|
||||
expect(persistMemoryRunExposureBeforeContent(second)).toBe(true);
|
||||
|
||||
clearMemoryRunExposureForTest();
|
||||
const durable = readDurableMemoryRunExposure({
|
||||
database: mocks.database as never,
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
});
|
||||
expect(durable).toMatchObject({
|
||||
exposureSetId: second.exposureSetId,
|
||||
revisionNumber: 2,
|
||||
previous: { exposureSetId: first.exposureSetId, revisionNumber: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears an old state root's process tail before starting the same run in a fresh ledger", () => {
|
||||
const staleStateDatabase = database as DatabaseSync;
|
||||
const staleSnapshot = prepare("session-a");
|
||||
expect(persistMemoryRunExposureBeforeContent(staleSnapshot)).toBe(true);
|
||||
expect(
|
||||
hydrateMemoryRunExposureFromLedger({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const freshStateDatabase = new DatabaseSync(":memory:");
|
||||
database = freshStateDatabase;
|
||||
mocks.database = {
|
||||
agentId: "main",
|
||||
db: freshStateDatabase,
|
||||
path: ":memory:",
|
||||
walMaintenance: undefined as never,
|
||||
};
|
||||
expect(
|
||||
hydrateMemoryRunExposureFromLedger({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const freshSnapshot = prepare("session-a");
|
||||
expect(freshSnapshot.revisionNumber).toBe(1);
|
||||
expect(persistMemoryRunExposureBeforeContent(freshSnapshot)).toBe(true);
|
||||
expect(
|
||||
freshStateDatabase
|
||||
.prepare(
|
||||
`SELECT revision_number FROM memory_preoutput_exposure_ledger
|
||||
WHERE agent_id = 'main' AND session_id = 'session-a' AND run_id = 'shared-run-id'`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ revision_number: 1 }]);
|
||||
|
||||
staleStateDatabase.close();
|
||||
});
|
||||
|
||||
it("fails closed when a different durable tail conflicts with the process tail", () => {
|
||||
const firstStateDatabase = database as DatabaseSync;
|
||||
const firstSnapshot = prepare("session-a");
|
||||
expect(persistMemoryRunExposureBeforeContent(firstSnapshot)).toBe(true);
|
||||
|
||||
const secondStateDatabase = new DatabaseSync(":memory:");
|
||||
mocks.database = {
|
||||
agentId: "main",
|
||||
db: secondStateDatabase,
|
||||
path: ":memory:",
|
||||
walMaintenance: undefined as never,
|
||||
};
|
||||
const secondSnapshot = prepare("session-a");
|
||||
expect(secondSnapshot.revisionNumber).toBe(1);
|
||||
expect(persistMemoryRunExposureBeforeContent(secondSnapshot)).toBe(true);
|
||||
|
||||
mocks.database = {
|
||||
agentId: "main",
|
||||
db: firstStateDatabase,
|
||||
path: ":memory:",
|
||||
walMaintenance: undefined as never,
|
||||
};
|
||||
expect(
|
||||
hydrateMemoryRunExposureFromLedger({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
mocks.database = {
|
||||
agentId: "main",
|
||||
db: secondStateDatabase,
|
||||
path: ":memory:",
|
||||
walMaintenance: undefined as never,
|
||||
};
|
||||
expect(
|
||||
hydrateMemoryRunExposureFromLedger({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
secondStateDatabase.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import type { AudienceRef } from "../memory-host-sdk/host/authorization.js";
|
||||
import { ensureMemoryPreoutputExposureLedgerSchemaInTransaction } from "../state/openclaw-agent-db-schema-helpers.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import {
|
||||
createMemoryRunExposureScopeId,
|
||||
reconcileMemoryRunExposureWithDurableLedger,
|
||||
type MemoryRunExposureSnapshot,
|
||||
} from "./memory-run-exposure.js";
|
||||
|
||||
type MemoryPreoutputExposureLedgerDatabase = {
|
||||
memory_preoutput_exposure_ledger: {
|
||||
agent_id: string;
|
||||
session_id: string;
|
||||
run_id: string;
|
||||
revision_number: number;
|
||||
exposure_set_id: string;
|
||||
previous_exposure_set_id: string | null;
|
||||
session_key: string;
|
||||
context_fingerprint: string;
|
||||
plan_id: string;
|
||||
memory_policy_revision: string;
|
||||
source_policy_set_ids_json: string;
|
||||
exposed_resource_revisions_json: string;
|
||||
exposure_receipt_ids_json: string;
|
||||
egress_receipt_ids_json: string;
|
||||
delivery_audiences_json: string;
|
||||
delivery_revision: string;
|
||||
egress_registry_revision: string;
|
||||
session_identity_revision: string;
|
||||
subject_revision: string;
|
||||
created_at: number;
|
||||
};
|
||||
};
|
||||
|
||||
type MemoryExposureLedgerDiagnostic = "hydrate-failed" | "persist-failed";
|
||||
|
||||
function logMemoryExposureLedgerDiagnostic(diagnostic: MemoryExposureLedgerDiagnostic): void {
|
||||
// Ledger errors can carry SQLite paths or other sensitive runtime details. The read already
|
||||
// fails closed, so emit only a stable outcome code for operators and tests.
|
||||
logWarn(`memory exposure ledger unavailable: ${diagnostic}`);
|
||||
}
|
||||
|
||||
function canonicalStrings(values: readonly string[]): string | undefined {
|
||||
if (values.some((value) => !value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.stringify([...new Set(values)].toSorted());
|
||||
}
|
||||
|
||||
function canonicalAudiences(snapshot: MemoryRunExposureSnapshot): string | undefined {
|
||||
const audiences = snapshot.deliveryAudiences.map((audience) => ({
|
||||
kind: audience.kind,
|
||||
id: audience.id,
|
||||
}));
|
||||
if (audiences.some((audience) => !audience.id.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
const keys = audiences.map((audience) => `${audience.kind}\u0000${audience.id}`);
|
||||
if (new Set(keys).size !== keys.length) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.stringify(
|
||||
audiences.toSorted((left, right) =>
|
||||
`${left.kind}\u0000${left.id}`.localeCompare(`${right.kind}\u0000${right.id}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseCanonicalStrings(value: string): readonly string[] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.some((entry) => typeof entry !== "string" || !entry.trim())
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const strings = parsed as string[];
|
||||
return canonicalStrings(strings) === value ? Object.freeze(strings) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const audienceKinds = new Set<AudienceRef["kind"]>([
|
||||
"user",
|
||||
"conversation",
|
||||
"role",
|
||||
"agent-shared",
|
||||
"agent",
|
||||
"internal",
|
||||
]);
|
||||
|
||||
function parseCanonicalAudiences(value: string): readonly AudienceRef[] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const audiences: AudienceRef[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== "object" ||
|
||||
!audienceKinds.has((entry as { kind?: unknown }).kind as AudienceRef["kind"]) ||
|
||||
typeof (entry as { id?: unknown }).id !== "string" ||
|
||||
!(entry as { id: string }).id.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
audiences.push(
|
||||
Object.freeze({
|
||||
kind: (entry as { kind: AudienceRef["kind"] }).kind,
|
||||
id: (entry as { id: string }).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const snapshot = { deliveryAudiences: audiences } as MemoryRunExposureSnapshot;
|
||||
return canonicalAudiences(snapshot) === value ? Object.freeze(audiences) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isDurableSnapshot(snapshot: MemoryRunExposureSnapshot): boolean {
|
||||
return Boolean(
|
||||
snapshot.agentId.trim() &&
|
||||
snapshot.sessionId.trim() &&
|
||||
snapshot.runId.trim() &&
|
||||
snapshot.sessionKey.trim() &&
|
||||
snapshot.contextFingerprint.trim() &&
|
||||
snapshot.planId.trim() &&
|
||||
snapshot.memoryPolicyRevision.trim() &&
|
||||
snapshot.deliveryRevision.trim() &&
|
||||
snapshot.egressRegistryRevision.trim() &&
|
||||
snapshot.sessionIdentityRevision.trim() &&
|
||||
snapshot.subjectRevision.trim() &&
|
||||
snapshot.revisionNumber > 0 &&
|
||||
snapshot.revisionNumber === (snapshot.previous?.revisionNumber ?? 0) + 1 &&
|
||||
snapshot.durableRunScopeId === createMemoryRunExposureScopeId(snapshot) &&
|
||||
(!snapshot.previous ||
|
||||
(snapshot.previous.agentId === snapshot.agentId &&
|
||||
snapshot.previous.sessionId === snapshot.sessionId &&
|
||||
snapshot.previous.runId === snapshot.runId)),
|
||||
);
|
||||
}
|
||||
|
||||
function persistMemoryRunExposureInTransaction(params: {
|
||||
database: OpenClawAgentDatabase;
|
||||
snapshot: MemoryRunExposureSnapshot;
|
||||
sourcePolicySetIdsJson: string;
|
||||
exposedResourceRevisionsJson: string;
|
||||
exposureReceiptIdsJson: string;
|
||||
egressReceiptIdsJson: string;
|
||||
deliveryAudiencesJson: string;
|
||||
}): void {
|
||||
const { database, snapshot } = params;
|
||||
ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database.db);
|
||||
const db = getNodeSqliteKysely<MemoryPreoutputExposureLedgerDatabase>(database.db);
|
||||
const inserted = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.insertInto("memory_preoutput_exposure_ledger")
|
||||
.values({
|
||||
agent_id: snapshot.agentId,
|
||||
session_id: snapshot.sessionId,
|
||||
run_id: snapshot.runId,
|
||||
revision_number: snapshot.revisionNumber,
|
||||
exposure_set_id: snapshot.exposureSetId,
|
||||
previous_exposure_set_id: snapshot.previous?.exposureSetId ?? null,
|
||||
session_key: snapshot.sessionKey,
|
||||
context_fingerprint: snapshot.contextFingerprint,
|
||||
plan_id: snapshot.planId,
|
||||
memory_policy_revision: snapshot.memoryPolicyRevision,
|
||||
source_policy_set_ids_json: params.sourcePolicySetIdsJson,
|
||||
exposed_resource_revisions_json: params.exposedResourceRevisionsJson,
|
||||
exposure_receipt_ids_json: params.exposureReceiptIdsJson,
|
||||
egress_receipt_ids_json: params.egressReceiptIdsJson,
|
||||
delivery_audiences_json: params.deliveryAudiencesJson,
|
||||
delivery_revision: snapshot.deliveryRevision,
|
||||
egress_registry_revision: snapshot.egressRegistryRevision,
|
||||
session_identity_revision: snapshot.sessionIdentityRevision,
|
||||
subject_revision: snapshot.subjectRevision,
|
||||
created_at: snapshot.createdAt,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["agent_id", "session_id", "run_id", "revision_number"]).doNothing(),
|
||||
),
|
||||
);
|
||||
if (inserted.numAffectedRows !== 1n) {
|
||||
throw new Error("memory exposure revision already has a durable ledger row");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a content-free audit row before a broker can publish selected-plugin content.
|
||||
* A duplicate revision is a concurrent/stale invocation, not a successful idempotent exposure.
|
||||
*/
|
||||
export function persistMemoryRunExposureBeforeContent(
|
||||
snapshot: MemoryRunExposureSnapshot,
|
||||
): boolean {
|
||||
const sourcePolicySetIdsJson = canonicalStrings(snapshot.sourcePolicySetIds);
|
||||
const exposedResourceRevisionsJson = canonicalStrings(snapshot.exposedResourceRevisions);
|
||||
const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds);
|
||||
const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds);
|
||||
const deliveryAudiencesJson = canonicalAudiences(snapshot);
|
||||
if (
|
||||
!isDurableSnapshot(snapshot) ||
|
||||
!sourcePolicySetIdsJson ||
|
||||
!exposedResourceRevisionsJson ||
|
||||
!exposureReceiptIdsJson ||
|
||||
!egressReceiptIdsJson ||
|
||||
!deliveryAudiencesJson
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return persistMemoryRunExposureBeforeContentInDatabase({
|
||||
database: openOpenClawAgentDatabase({ agentId: snapshot.agentId }),
|
||||
snapshot,
|
||||
});
|
||||
} catch {
|
||||
logMemoryExposureLedgerDiagnostic("persist-failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses an already-owned agent DB for deterministic test and lifecycle setup. */
|
||||
export function persistMemoryRunExposureBeforeContentInDatabase(params: {
|
||||
database: OpenClawAgentDatabase;
|
||||
snapshot: MemoryRunExposureSnapshot;
|
||||
}): boolean {
|
||||
const { database, snapshot } = params;
|
||||
const sourcePolicySetIdsJson = canonicalStrings(snapshot.sourcePolicySetIds);
|
||||
const exposedResourceRevisionsJson = canonicalStrings(snapshot.exposedResourceRevisions);
|
||||
const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds);
|
||||
const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds);
|
||||
const deliveryAudiencesJson = canonicalAudiences(snapshot);
|
||||
if (
|
||||
database.agentId !== snapshot.agentId ||
|
||||
!isDurableSnapshot(snapshot) ||
|
||||
!sourcePolicySetIdsJson ||
|
||||
!exposedResourceRevisionsJson ||
|
||||
!exposureReceiptIdsJson ||
|
||||
!egressReceiptIdsJson ||
|
||||
!deliveryAudiencesJson
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
runSqliteImmediateTransactionSync(database.db, () => {
|
||||
persistMemoryRunExposureInTransaction({
|
||||
database,
|
||||
snapshot,
|
||||
sourcePolicySetIdsJson,
|
||||
exposedResourceRevisionsJson,
|
||||
exposureReceiptIdsJson,
|
||||
egressReceiptIdsJson,
|
||||
deliveryAudiencesJson,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
logMemoryExposureLedgerDiagnostic("persist-failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrates the immutable, session-bound exposure lineage from the pre-output ledger.
|
||||
* Transcript companions use this durable authority after a gateway restart, never the process Map.
|
||||
*/
|
||||
export function readDurableMemoryRunExposure(params: {
|
||||
database: OpenClawAgentDatabase;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
}): MemoryRunExposureSnapshot | undefined {
|
||||
try {
|
||||
return readDurableMemoryRunExposureOrThrow(params);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readDurableMemoryRunExposureOrThrow(params: {
|
||||
database: OpenClawAgentDatabase;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
}): MemoryRunExposureSnapshot | undefined {
|
||||
const db = getNodeSqliteKysely<MemoryPreoutputExposureLedgerDatabase>(params.database.db);
|
||||
const rows = executeSqliteQuerySync(
|
||||
params.database.db,
|
||||
db
|
||||
.selectFrom("memory_preoutput_exposure_ledger")
|
||||
.selectAll()
|
||||
.where("agent_id", "=", params.database.agentId)
|
||||
.where("session_id", "=", params.sessionId)
|
||||
.where("run_id", "=", params.runId)
|
||||
.orderBy("revision_number", "asc"),
|
||||
).rows;
|
||||
let previous: MemoryRunExposureSnapshot | undefined;
|
||||
for (const row of rows) {
|
||||
const sourcePolicySetIds = parseCanonicalStrings(row.source_policy_set_ids_json);
|
||||
const exposedResourceRevisions = parseCanonicalStrings(row.exposed_resource_revisions_json);
|
||||
const exposureReceiptIds = parseCanonicalStrings(row.exposure_receipt_ids_json);
|
||||
const egressReceiptIds = parseCanonicalStrings(row.egress_receipt_ids_json);
|
||||
const deliveryAudiences = parseCanonicalAudiences(row.delivery_audiences_json);
|
||||
if (
|
||||
!sourcePolicySetIds ||
|
||||
!exposedResourceRevisions ||
|
||||
!exposureReceiptIds ||
|
||||
!egressReceiptIds ||
|
||||
!deliveryAudiences ||
|
||||
!row.session_key.trim() ||
|
||||
!row.context_fingerprint.trim() ||
|
||||
!row.plan_id.trim() ||
|
||||
!row.memory_policy_revision.trim() ||
|
||||
!row.delivery_revision.trim() ||
|
||||
!row.egress_registry_revision.trim() ||
|
||||
!row.session_identity_revision.trim() ||
|
||||
!row.subject_revision.trim() ||
|
||||
row.revision_number !== (previous?.revisionNumber ?? 0) + 1 ||
|
||||
row.previous_exposure_set_id !== (previous?.exposureSetId ?? null)
|
||||
) {
|
||||
throw new Error("memory exposure ledger has an invalid durable lineage");
|
||||
}
|
||||
previous = Object.freeze({
|
||||
exposureSetId: row.exposure_set_id,
|
||||
revisionNumber: row.revision_number,
|
||||
...(previous ? { previous } : {}),
|
||||
agentId: row.agent_id,
|
||||
sessionId: row.session_id,
|
||||
sessionKey: row.session_key,
|
||||
runId: row.run_id,
|
||||
durableRunScopeId: createMemoryRunExposureScopeId({
|
||||
agentId: row.agent_id,
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
}),
|
||||
contextFingerprint: row.context_fingerprint,
|
||||
planId: row.plan_id,
|
||||
memoryPolicyRevision: row.memory_policy_revision,
|
||||
sourcePolicySetIds,
|
||||
exposedResourceRevisions,
|
||||
exposureReceiptIds,
|
||||
egressReceiptIds,
|
||||
deliveryAudiences,
|
||||
deliveryRevision: row.delivery_revision,
|
||||
egressRegistryRevision: row.egress_registry_revision,
|
||||
sessionIdentityRevision: row.session_identity_revision,
|
||||
subjectRevision: row.subject_revision,
|
||||
createdAt: row.created_at,
|
||||
}) satisfies MemoryRunExposureSnapshot;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles process state with the durable tail before preparing a new content release. A corrupt
|
||||
* or mismatched ledger fails the broker closed rather than advancing from an unsafe process tail.
|
||||
*/
|
||||
export function hydrateMemoryRunExposureFromLedger(params: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
}): boolean {
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase({ agentId: params.agentId });
|
||||
ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database.db);
|
||||
const snapshot = readDurableMemoryRunExposureOrThrow({
|
||||
database,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
});
|
||||
return reconcileMemoryRunExposureWithDurableLedger({
|
||||
...params,
|
||||
durableSnapshot: snapshot,
|
||||
});
|
||||
} catch {
|
||||
logMemoryExposureLedgerDiagnostic("hydrate-failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { AudienceRef } from "../memory-host-sdk/host/authorization.js";
|
||||
|
||||
export type MemoryRunExposureSnapshot = Readonly<{
|
||||
@@ -9,6 +9,7 @@ export type MemoryRunExposureSnapshot = Readonly<{
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
durableRunScopeId: string;
|
||||
contextFingerprint: string;
|
||||
planId: string;
|
||||
memoryPolicyRevision: string;
|
||||
@@ -26,7 +27,7 @@ export type MemoryRunExposureSnapshot = Readonly<{
|
||||
|
||||
type MemoryRunExposureFacts = Omit<
|
||||
MemoryRunExposureSnapshot,
|
||||
"exposureSetId" | "revisionNumber" | "previous" | "createdAt"
|
||||
"exposureSetId" | "revisionNumber" | "previous" | "createdAt" | "durableRunScopeId"
|
||||
>;
|
||||
|
||||
const exposuresByRun = new Map<string, MemoryRunExposureSnapshot>();
|
||||
@@ -35,6 +36,18 @@ function key(params: { agentId: string; sessionId: string; runId: string }): str
|
||||
return `${params.agentId}\u0000${params.sessionId}\u0000${params.runId}`;
|
||||
}
|
||||
|
||||
/** Makes legacy projection keys session-bound without exposing raw session ids in that surface. */
|
||||
export function createMemoryRunExposureScopeId(params: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
}): string {
|
||||
const { agentId, sessionId, runId } = params;
|
||||
return `mre-scope1_${createHash("sha256")
|
||||
.update(JSON.stringify({ agentId, sessionId, runId }))
|
||||
.digest("base64url")}`;
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): readonly string[] {
|
||||
return Object.freeze([...new Set(values)].toSorted());
|
||||
}
|
||||
@@ -53,15 +66,16 @@ function sortedAudiences(audiences: readonly AudienceRef[]): readonly AudienceRe
|
||||
);
|
||||
}
|
||||
|
||||
/** Records an immutable run-exposure revision before scoped content leaves the broker. */
|
||||
export function recordMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryRunExposureSnapshot {
|
||||
/** Prepares an immutable run-exposure revision without publishing it to process state. */
|
||||
export function prepareMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryRunExposureSnapshot {
|
||||
const normalizedKey = key(facts);
|
||||
const previous = exposuresByRun.get(normalizedKey);
|
||||
const snapshot = Object.freeze({
|
||||
return Object.freeze({
|
||||
exposureSetId: `mre1_${randomUUID()}`,
|
||||
revisionNumber: (previous?.revisionNumber ?? 0) + 1,
|
||||
...(previous ? { previous } : {}),
|
||||
...facts,
|
||||
durableRunScopeId: createMemoryRunExposureScopeId(facts),
|
||||
sourcePolicySetIds: sortedUnique(facts.sourcePolicySetIds),
|
||||
exposedResourceRevisions: sortedUnique(facts.exposedResourceRevisions),
|
||||
exposureReceiptIds: sortedUnique(facts.exposureReceiptIds),
|
||||
@@ -69,7 +83,55 @@ export function recordMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryRu
|
||||
deliveryAudiences: sortedAudiences(facts.deliveryAudiences),
|
||||
createdAt: Date.now(),
|
||||
}) satisfies MemoryRunExposureSnapshot;
|
||||
}
|
||||
|
||||
/** Publishes a prepared revision only when no competing revision has advanced this run. */
|
||||
export function publishMemoryRunExposure(snapshot: MemoryRunExposureSnapshot): boolean {
|
||||
const normalizedKey = key(snapshot);
|
||||
if (exposuresByRun.get(normalizedKey) !== snapshot.previous) {
|
||||
return false;
|
||||
}
|
||||
exposuresByRun.set(normalizedKey, snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the durable ledger authoritative for one run. Empty durable state clears a stale
|
||||
* process entry after a state-root change; a distinct durable tail is unsafe to overwrite.
|
||||
*/
|
||||
export function reconcileMemoryRunExposureWithDurableLedger(params: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
durableSnapshot: MemoryRunExposureSnapshot | undefined;
|
||||
}): boolean {
|
||||
const normalizedKey = key(params);
|
||||
const current = exposuresByRun.get(normalizedKey);
|
||||
const { durableSnapshot } = params;
|
||||
if (!durableSnapshot) {
|
||||
exposuresByRun.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
durableSnapshot.agentId !== params.agentId ||
|
||||
durableSnapshot.sessionId !== params.sessionId ||
|
||||
durableSnapshot.runId !== params.runId ||
|
||||
(current &&
|
||||
(current.exposureSetId !== durableSnapshot.exposureSetId ||
|
||||
current.revisionNumber !== durableSnapshot.revisionNumber))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
exposuresByRun.set(normalizedKey, durableSnapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Records an immutable run-exposure revision for callers that do not need durable pre-output fencing. */
|
||||
export function recordMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryRunExposureSnapshot {
|
||||
const snapshot = prepareMemoryRunExposure(facts);
|
||||
if (!publishMemoryRunExposure(snapshot)) {
|
||||
throw new Error("memory run exposure advanced before publication");
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,16 +185,15 @@ describe("memory plugin state", () => {
|
||||
registerMemoryCapability("memory-core", {
|
||||
publicArtifacts: { listArtifacts },
|
||||
});
|
||||
isMemoryIsolationCutoverAgentMock.mockImplementation((agentId: string) => agentId === "cutover");
|
||||
isMemoryIsolationCutoverAgentMock.mockImplementation(
|
||||
(agentId: string) => agentId === "cutover",
|
||||
);
|
||||
|
||||
await expect(
|
||||
listActiveMemoryPublicArtifacts({
|
||||
cfg: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "legacy", default: true },
|
||||
{ id: "cutover" },
|
||||
],
|
||||
list: [{ id: "legacy", default: true }, { id: "cutover" }],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
@@ -503,6 +502,56 @@ describe("memory plugin state", () => {
|
||||
expect(supplemental).toHaveBeenCalledWith(expectedContext);
|
||||
});
|
||||
|
||||
it("fails closed for unbound cut-over prompt contributors", async () => {
|
||||
isMemoryIsolationCutoverAgentMock.mockReturnValue(true);
|
||||
const primary = vi.fn(() => ["selected runtime"]);
|
||||
const supplemental = vi.fn(() => ["legacy supplement"]);
|
||||
const prepare = vi.fn(async () => ["legacy prepared supplement"]);
|
||||
registerTestMemoryPromptBuilder(primary);
|
||||
registerMemoryPromptSupplement("memory-wiki", supplemental);
|
||||
registerMemoryPromptPreparation("memory-wiki", prepare);
|
||||
|
||||
const params = {
|
||||
availableTools: new Set<string>(),
|
||||
agentId: "cut-over",
|
||||
agentSessionKey: "agent:cut-over:main",
|
||||
};
|
||||
expect(buildMemoryPromptSection(params)).toEqual([]);
|
||||
await expect(prepareMemoryPromptSection(params)).resolves.toMatchObject({ lines: [] });
|
||||
expect(primary).not.toHaveBeenCalled();
|
||||
expect(supplemental).not.toHaveBeenCalled();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds selected-runtime prompt state to the host invocation and blocks supplements", async () => {
|
||||
isMemoryIsolationCutoverAgentMock.mockReturnValue(true);
|
||||
const host = {
|
||||
search: vi.fn(async () => ({ results: [] })),
|
||||
read: vi.fn(async () => ({ text: "", path: "" })),
|
||||
};
|
||||
const primary = vi.fn(() => ["selected runtime"]);
|
||||
const supplemental = vi.fn(() => ["legacy supplement"]);
|
||||
const prepare = vi.fn(async () => ["legacy prepared supplement"]);
|
||||
registerTestMemoryPromptBuilder(primary);
|
||||
registerMemoryPromptSupplement("memory-wiki", supplemental);
|
||||
registerMemoryPromptPreparation("memory-wiki", prepare);
|
||||
|
||||
const params = {
|
||||
availableTools: new Set<string>(),
|
||||
agentId: "cut-over",
|
||||
agentSessionKey: "agent:cut-over:main",
|
||||
authorizedMemoryRead: host,
|
||||
};
|
||||
const prepared = await prepareMemoryPromptSection(params);
|
||||
expect(buildMemoryPromptSection(params, prepared)).toEqual(["selected runtime"]);
|
||||
expect(primary).toHaveBeenCalledWith(expect.objectContaining({ authorizedMemoryRead: host }));
|
||||
expect(supplemental).not.toHaveBeenCalled();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(() =>
|
||||
buildMemoryPromptSection({ ...params, authorizedMemoryRead: { ...host } }, prepared),
|
||||
).toThrow("prepared memory prompt section does not match the current run");
|
||||
});
|
||||
|
||||
it("appends prompt supplements in plugin-id order", () => {
|
||||
registerTestMemoryPromptBuilder(() => ["primary"]);
|
||||
registerMemoryPromptSupplement("memory-wiki", () => ["wiki"]);
|
||||
|
||||
+24
-11
@@ -1,9 +1,10 @@
|
||||
/** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { filterStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listAgentIds } from "../agents/agent-scope-config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "./memory-cutover.js";
|
||||
import type {
|
||||
MemoryCorpusSupplement,
|
||||
MemoryCorpusSupplementRegistration,
|
||||
@@ -19,7 +20,6 @@ import type {
|
||||
MemoryPromptSupplementRegistration,
|
||||
PreparedMemoryPromptSection,
|
||||
} from "./registry-contribution-types.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "./memory-cutover.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
import { requireActivePluginRegistry, resolveDirectPluginRegistrationOwner } from "./runtime.js";
|
||||
|
||||
@@ -174,11 +174,16 @@ function buildSynchronousMemoryPromptSection(params: MemoryPromptSectionParams):
|
||||
primary: string[];
|
||||
supplements: Array<{ pluginId: string; lines: string[] }>;
|
||||
} {
|
||||
if (params.memoryReadEnforced && !params.authorizedMemoryRead) {
|
||||
// Agent/session strings are routing hints, never authority. Do not let an
|
||||
// unbound prompt contributor turn a missing host invocation into content.
|
||||
return { primary: [], supplements: [] };
|
||||
}
|
||||
const registry = requireActivePluginRegistry();
|
||||
const primary = filterStringEntries(
|
||||
resolveSelectedMemoryCapabilityRegistration(registry)?.capability.promptBuilder?.(params) ?? [],
|
||||
);
|
||||
const supplements = registry.memoryPromptSupplements
|
||||
const supplements = (params.memoryReadEnforced ? [] : registry.memoryPromptSupplements)
|
||||
// Keep supplement order stable even if plugin registration order changes.
|
||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
|
||||
.map((registration) => ({
|
||||
@@ -197,7 +202,10 @@ function cloneMemoryPromptSectionParams(
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed,
|
||||
memoryReadEnforced: params.memoryReadEnforced,
|
||||
memoryReadEnforced:
|
||||
params.memoryReadEnforced ??
|
||||
(params.agentId && isMemoryIsolationCutoverAgent(params.agentId) ? true : undefined),
|
||||
authorizedMemoryRead: params.authorizedMemoryRead,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,6 +219,7 @@ function snapshotMemoryPromptContext(
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed === true,
|
||||
memoryReadEnforced: params.memoryReadEnforced === true,
|
||||
...(params.authorizedMemoryRead ? { authorizedMemoryRead: params.authorizedMemoryRead } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,10 +230,11 @@ function preparedMemoryPromptContextMatches(
|
||||
const current = snapshotMemoryPromptContext(params);
|
||||
return (
|
||||
prepared.context.citationsMode === current.citationsMode &&
|
||||
prepared.context.agentId === current.agentId &&
|
||||
prepared.context.agentSessionKey === current.agentSessionKey &&
|
||||
prepared.context.sandboxed === current.sandboxed &&
|
||||
prepared.context.memoryReadEnforced === current.memoryReadEnforced &&
|
||||
prepared.context.agentId === current.agentId &&
|
||||
prepared.context.agentSessionKey === current.agentSessionKey &&
|
||||
prepared.context.sandboxed === current.sandboxed &&
|
||||
prepared.context.memoryReadEnforced === current.memoryReadEnforced &&
|
||||
prepared.context.authorizedMemoryRead === current.authorizedMemoryRead &&
|
||||
prepared.context.availableTools.length === current.availableTools.length &&
|
||||
prepared.context.availableTools.every((tool, index) => tool === current.availableTools[index])
|
||||
);
|
||||
@@ -240,8 +250,11 @@ export async function prepareMemoryPromptSection(
|
||||
cloneMemoryPromptSectionParams(runParams),
|
||||
);
|
||||
const preparationRegistrations = [...requireActivePluginRegistry().memoryPromptPreparations];
|
||||
// Registered preparations are supplemental paths. Until a contributor can
|
||||
// project through the selected runtime, it stays unavailable after cutover.
|
||||
const canPrepare = !runParams.memoryReadEnforced;
|
||||
const preparedSupplements = await Promise.all(
|
||||
preparationRegistrations.map(async (registration) => ({
|
||||
(canPrepare ? preparationRegistrations : []).map(async (registration) => ({
|
||||
pluginId: registration.pluginId,
|
||||
lines: filterStringEntries(
|
||||
await registration.prepare(cloneMemoryPromptSectionParams(runParams)),
|
||||
@@ -283,13 +296,13 @@ export function buildMemoryPromptSection(
|
||||
// Run-scoped prompt state must never cross agent/session/tool boundaries.
|
||||
if (
|
||||
!preparedMemoryPromptSections.has(prepared) ||
|
||||
!preparedMemoryPromptContextMatches(prepared, params)
|
||||
!preparedMemoryPromptContextMatches(prepared, cloneMemoryPromptSectionParams(params))
|
||||
) {
|
||||
throw new Error("prepared memory prompt section does not match the current run");
|
||||
}
|
||||
return [...prepared.lines];
|
||||
}
|
||||
const synchronous = buildSynchronousMemoryPromptSection(params);
|
||||
const synchronous = buildSynchronousMemoryPromptSection(cloneMemoryPromptSectionParams(params));
|
||||
return [...synchronous.primary, ...synchronous.supplements.flatMap((entry) => entry.lines)];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
EmbeddingProviderIndexIdentity,
|
||||
EmbeddingProviderRuntime,
|
||||
} from "./embedding-provider-types.js";
|
||||
import type { AuthorizedMemoryReadHost } from "./tool-types.js";
|
||||
|
||||
export type ContextEngineFactoryContext = {
|
||||
config?: OpenClawConfig;
|
||||
@@ -145,6 +146,12 @@ export type MemoryPromptSectionParams = {
|
||||
sandboxed?: boolean;
|
||||
/** True when supplemental legacy memory reads are unavailable for this agent. */
|
||||
memoryReadEnforced?: true;
|
||||
/**
|
||||
* Host-minted invocation for this run's selected authorized memory runtime.
|
||||
* In enforced mode, prompt contributors must not derive access from agent or
|
||||
* session strings; an absent handle means their content path is unavailable.
|
||||
*/
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
};
|
||||
|
||||
export type MemoryPromptSectionBuilder = (params: MemoryPromptSectionParams) => string[];
|
||||
@@ -161,6 +168,7 @@ export type PreparedMemoryPromptSection = Readonly<{
|
||||
agentSessionKey?: string;
|
||||
sandboxed: boolean;
|
||||
memoryReadEnforced: boolean;
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
}>;
|
||||
lines: readonly string[];
|
||||
}>;
|
||||
@@ -206,6 +214,8 @@ export type MemoryCorpusSupplement = {
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
memoryReadEnforced?: true;
|
||||
/** Host-minted invocation for this run; required for enforced content access. */
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
}): Promise<MemoryCorpusSearchResult[]>;
|
||||
get(params: {
|
||||
lookup: string;
|
||||
@@ -215,6 +225,8 @@ export type MemoryCorpusSupplement = {
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
memoryReadEnforced?: true;
|
||||
/** Host-minted invocation for this run; required for enforced content access. */
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
}): Promise<MemoryCorpusGetResult | null>;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
AGENT_SCOPED_MEMORY_FTS_TABLE,
|
||||
AGENT_SCOPED_MEMORY_FTS_TRIGGER_DEFINITIONS,
|
||||
AGENT_SCOPED_MEMORY_TABLES,
|
||||
ensureOpenClawAgentScopedMemorySchema,
|
||||
} from "./openclaw-agent-scoped-memory-schema.js";
|
||||
import {
|
||||
AGENT_V14_ADDITIVE_SCHEMA_SQL,
|
||||
@@ -167,7 +168,6 @@ function repairAndAssertAgentSchemaGroup(
|
||||
|
||||
const SESSION_KEY_CONTRACT_SCHEMA_START = "CREATE TABLE IF NOT EXISTS session_key_contract (";
|
||||
const SESSION_KEY_CONTRACT_SCHEMA_END = "CREATE TABLE IF NOT EXISTS session_windows (";
|
||||
|
||||
/** Ensure the additive session-key contract table inside the caller's transaction. */
|
||||
export function ensureSessionKeyContractSchemaInTransaction(db: DatabaseSync): void {
|
||||
const start = OPENCLAW_AGENT_SCHEMA_SQL.indexOf(SESSION_KEY_CONTRACT_SCHEMA_START);
|
||||
@@ -178,6 +178,11 @@ export function ensureSessionKeyContractSchemaInTransaction(db: DatabaseSync): v
|
||||
db.exec(OPENCLAW_AGENT_SCHEMA_SQL.slice(start, end)); // sqlite-allow-raw -- Idempotent additive lazy ensure.
|
||||
}
|
||||
|
||||
/** Ensure the scoped-read audit ledger inside the caller's synchronous write transaction. */
|
||||
export function ensureMemoryPreoutputExposureLedgerSchemaInTransaction(db: DatabaseSync): void {
|
||||
ensureOpenClawAgentScopedMemorySchema(db);
|
||||
}
|
||||
|
||||
export function repairAndAssertOpenClawAgentV14SchemaForMigration(
|
||||
database: DatabaseSync,
|
||||
options: { agentId: string; pathname: string },
|
||||
|
||||
+24
@@ -263,6 +263,29 @@ export interface MemoryPolicySets {
|
||||
policy_set_id: string;
|
||||
}
|
||||
|
||||
export interface MemoryPreoutputExposureLedger {
|
||||
agent_id: string;
|
||||
context_fingerprint: string;
|
||||
created_at: number;
|
||||
delivery_audiences_json: string;
|
||||
delivery_revision: string;
|
||||
egress_receipt_ids_json: string;
|
||||
egress_registry_revision: string;
|
||||
exposed_resource_revisions_json: string;
|
||||
exposure_receipt_ids_json: string;
|
||||
exposure_set_id: string;
|
||||
memory_policy_revision: string;
|
||||
plan_id: string;
|
||||
previous_exposure_set_id: string | null;
|
||||
revision_number: number;
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
session_identity_revision: string;
|
||||
session_key: string;
|
||||
source_policy_set_ids_json: string;
|
||||
subject_revision: string;
|
||||
}
|
||||
|
||||
export interface MemoryResourceRevisions {
|
||||
activated_at: number | null;
|
||||
actor_id: string | null;
|
||||
@@ -732,6 +755,7 @@ export interface DB {
|
||||
memory_policy_entries: MemoryPolicyEntries;
|
||||
memory_policy_revisions: MemoryPolicyRevisions;
|
||||
memory_policy_sets: MemoryPolicySets;
|
||||
memory_preoutput_exposure_ledger: MemoryPreoutputExposureLedger;
|
||||
memory_resource_revisions: MemoryResourceRevisions;
|
||||
memory_resource_subjects: MemoryResourceSubjects;
|
||||
memory_resources: MemoryResources;
|
||||
|
||||
@@ -933,6 +933,48 @@ BEGIN
|
||||
SELECT RAISE(ABORT, 'memory run exposures cannot be deleted');
|
||||
END;
|
||||
|
||||
-- Selected-plugin content is never returned until this content-free ledger row commits.
|
||||
-- It is lazy/additive so current-version databases remain compatible until first scoped read.
|
||||
CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_ledger (
|
||||
agent_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL CHECK (revision_number > 0),
|
||||
exposure_set_id TEXT NOT NULL UNIQUE,
|
||||
previous_exposure_set_id TEXT,
|
||||
session_key TEXT NOT NULL,
|
||||
context_fingerprint TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL,
|
||||
memory_policy_revision TEXT NOT NULL,
|
||||
source_policy_set_ids_json TEXT NOT NULL,
|
||||
exposed_resource_revisions_json TEXT NOT NULL,
|
||||
exposure_receipt_ids_json TEXT NOT NULL,
|
||||
egress_receipt_ids_json TEXT NOT NULL,
|
||||
delivery_audiences_json TEXT NOT NULL,
|
||||
delivery_revision TEXT NOT NULL,
|
||||
egress_registry_revision TEXT NOT NULL,
|
||||
session_identity_revision TEXT NOT NULL,
|
||||
subject_revision TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (agent_id, session_id, run_id, revision_number),
|
||||
FOREIGN KEY (previous_exposure_set_id) REFERENCES memory_preoutput_exposure_ledger(exposure_set_id) ON DELETE RESTRICT
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_preoutput_exposure_ledger_session_run
|
||||
ON memory_preoutput_exposure_ledger(agent_id, session_id, run_id, revision_number DESC);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_ledger_no_update
|
||||
BEFORE UPDATE ON memory_preoutput_exposure_ledger
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'pre-output memory exposure ledger is immutable');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_ledger_no_delete
|
||||
BEFORE DELETE ON memory_preoutput_exposure_ledger
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'pre-output memory exposure ledger cannot be deleted');
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transcript_event_memory_policies (
|
||||
session_id TEXT NOT NULL,
|
||||
event_seq INTEGER NOT NULL,
|
||||
|
||||
@@ -144,4 +144,10 @@ describe("scoped memory additive agent schema", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the pre-output exposure ledger in the canonical scoped schema payload", () => {
|
||||
expect(AGENT_SCOPED_MEMORY_SCHEMA_SQL).toContain(
|
||||
"CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_ledger (",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export const AGENT_SCOPED_MEMORY_TABLES = [
|
||||
"memory_migrations",
|
||||
"memory_policy_sets",
|
||||
"memory_run_exposures",
|
||||
"memory_preoutput_exposure_ledger",
|
||||
"transcript_event_memory_policies",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { wrapToolWithGatewayCallerIdentity } from "../agents/tools/gateway-calle
|
||||
import { DEFAULT_AGENTS_FILENAME, loadWorkspaceBootstrapFiles } from "../agents/workspace.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { AssistantMessage, AssistantMessageEventStreamLike } from "../llm/types.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
import { createWorkerBrowserToolRuntime, type WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import { createWorkerLiveRuntime } from "./embedded-agent-live.runtime.js";
|
||||
@@ -98,6 +97,7 @@ type RunWorkerEmbeddedTurnParams = {
|
||||
permissionMode?: import("../../packages/gateway-protocol/src/schema/sessions-row.js").SessionPermissionMode;
|
||||
browser?: WorkerBrowserLaunchDescriptor;
|
||||
browserRuntime?: WorkerBrowserRuntime;
|
||||
memoryIsolationCutover: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -151,16 +151,21 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
});
|
||||
|
||||
const allowedToolNameSet = new Set<string>(params.allowedToolNames);
|
||||
const localToolNameSet = new Set<string>(WORKER_LOCAL_TOOL_NAMES);
|
||||
const permissionToolPolicy = params.permissionMode
|
||||
? resolveSessionPermissionCoreToolPolicy({ mode: params.permissionMode })
|
||||
: undefined;
|
||||
const omittedToolNames = permissionToolPolicy?.readOnly
|
||||
? new Set<WorkerToolName>(["write", "edit", "apply_patch"])
|
||||
: undefined;
|
||||
const activeToolNames = WORKER_TOOL_NAMES.filter(
|
||||
// P1C's selected-memory pilot exposes no mutation or execution path. The worker builds core
|
||||
// tools directly, so it must apply the primary agent's final read-only surface itself.
|
||||
const availableToolNames = params.memoryIsolationCutover
|
||||
? (["read"] as const)
|
||||
: WORKER_LOCAL_TOOL_NAMES;
|
||||
const activeToolNames = availableToolNames.filter(
|
||||
(name) => allowedToolNameSet.has(name) && !omittedToolNames?.has(name),
|
||||
);
|
||||
const localToolNameSet = new Set<string>(availableToolNames);
|
||||
const headlessApprovalText = params.permissionMode
|
||||
? `Exec denied (approval_required) in worker ${params.permissionMode} permission mode. Run this command locally for interactive approval, or ask an administrator to clear the session permission mode.`
|
||||
: undefined;
|
||||
@@ -168,19 +173,20 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
codingRoot: params.cwd,
|
||||
containmentRoot: params.workerContainmentRoot,
|
||||
includeBaseCodingTools: true,
|
||||
includeShellTools: true,
|
||||
includeShellTools: !params.memoryIsolationCutover,
|
||||
workspaceOnly: permissionToolPolicy?.workspaceOnly ?? false,
|
||||
readOnly: permissionToolPolicy?.readOnly ?? false,
|
||||
readOnly: params.memoryIsolationCutover || permissionToolPolicy?.readOnly === true,
|
||||
modelContextWindowTokens: model.contextWindow,
|
||||
imageSanitization: {},
|
||||
applyPatchEnabled:
|
||||
!params.memoryIsolationCutover &&
|
||||
permissionToolPolicy?.readOnly !== true &&
|
||||
isApplyPatchAllowedForModel({
|
||||
modelProvider: params.modelRef.provider,
|
||||
modelId: params.modelRef.model,
|
||||
}),
|
||||
applyPatchWorkspaceOnly: permissionToolPolicy?.applyPatchWorkspaceOnly ?? true,
|
||||
memoryFileMutationGuard: isMemoryIsolationCutoverAgent(DEFAULT_AGENT_ID)
|
||||
memoryFileMutationGuard: params.memoryIsolationCutover
|
||||
? createMemoryFileMutationGuard({ mutationRoot: params.cwd })
|
||||
: undefined,
|
||||
execDefaults: {
|
||||
@@ -247,7 +253,11 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
}),
|
||||
);
|
||||
const discoveredToolNames = new Set(localTools.map((tool) => tool.name));
|
||||
for (const toolName of WORKER_REQUIRED_LOCAL_TOOL_NAMES) {
|
||||
const requiredToolNames = [
|
||||
...(params.memoryIsolationCutover ? ["read"] : WORKER_REQUIRED_LOCAL_TOOL_NAMES),
|
||||
...(browserRuntime ? ["browser"] : []),
|
||||
];
|
||||
for (const toolName of requiredToolNames) {
|
||||
if (omittedToolNames?.has(toolName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ import {
|
||||
import { createDeferred, withTestTimeout } from "../../test/helpers/promise.js";
|
||||
import { createOperationalRunInstanceRef } from "../agents/admitted-run-context.js";
|
||||
import { listRunningSessions } from "../agents/bash-process-registry.js";
|
||||
import {
|
||||
enableMemoryShadowReadOnlyMode,
|
||||
resetMemoryIsolationCutoverForTest,
|
||||
} from "../plugins/memory-cutover.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE } from "./transcript-message.js";
|
||||
import { WorkerAdmissionDeadlineExceededError } from "./worker-connection-contract.js";
|
||||
@@ -896,6 +904,7 @@ describe("worker runtime", () => {
|
||||
const result = await runWorkerDescriptor(launch);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(browserRuntimeMocks.createWorkerBrowserToolRuntime).not.toHaveBeenCalled();
|
||||
expect(gateway.inferenceRequests).toHaveLength(1);
|
||||
expect(gateway.inferenceRequests[0]?.modelRef).toEqual(MODEL_REF);
|
||||
expect(gateway.inferenceRequests[0]?.context.systemPrompt).toContain("worker-bootstrap-marker");
|
||||
@@ -960,6 +969,44 @@ describe("worker runtime", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an enforced agent's worker tool surface read-only after state isolation", async () => {
|
||||
const memoryStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-memory-state-"));
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = memoryStateDir;
|
||||
try {
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main" });
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO session_memory_subjects
|
||||
(session_key, binding_id, principal_id, subject_kind, subject_revision, created_at)
|
||||
VALUES ('agent:main:pilot', NULL, 'principal-alice', 'agent', 'test-revision', 1)`,
|
||||
)
|
||||
.run();
|
||||
expect(enableMemoryShadowReadOnlyMode({ agentId: "main", nowMs: 1 })).toBe(
|
||||
"shadow-read-only",
|
||||
);
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
|
||||
const { gateway, launch } = await setup();
|
||||
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
||||
|
||||
expect(browserRuntimeMocks.createWorkerBrowserToolRuntime).not.toHaveBeenCalled();
|
||||
expect(gateway.inferenceRequests[0]?.context.tools?.map((tool) => tool.name)).toEqual([
|
||||
"read",
|
||||
]);
|
||||
} finally {
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await rm(memoryStateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs with no tools when the Gateway authority is empty", async () => {
|
||||
const { gateway, launch } = await setup();
|
||||
launch.assignment.toolAuthority.allowedToolNames = [];
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
import type { WorkerBrowserRuntime } from "./browser-runtime.js";
|
||||
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
||||
import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js";
|
||||
@@ -77,6 +79,9 @@ export async function runWorkerDescriptor(
|
||||
"worker workspace path escapes its assigned containment root; reprovision the worker workspace and retry",
|
||||
);
|
||||
}
|
||||
// Workers replace their state directory below. Resolve the durable P1C posture first so the
|
||||
// isolated runtime cannot reinterpret an enforced agent as legacy because its scratch DB is empty.
|
||||
const memoryIsolationCutover = isMemoryIsolationCutoverAgent(DEFAULT_AGENT_ID);
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-"));
|
||||
await chmod(stateDir, 0o700);
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
@@ -174,6 +179,7 @@ export async function runWorkerDescriptor(
|
||||
allowedToolNames: descriptor.assignment.toolAuthority.allowedToolNames,
|
||||
...(descriptor.assignment.browser ? { browser: descriptor.assignment.browser } : {}),
|
||||
...(options.browserRuntime ? { browserRuntime: options.browserRuntime } : {}),
|
||||
memoryIsolationCutover,
|
||||
inference: { stream },
|
||||
transcript: {
|
||||
commit: async (messages) => {
|
||||
|
||||
Reference in New Issue
Block a user