[plugin sdk] Project session extension slots (#75609)

Merged via squash.

Prepared head SHA: d9b670a867
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Eva
2026-05-04 22:04:27 +07:00
committed by GitHub
parent e3364ae3bd
commit cb38535875
27 changed files with 1974 additions and 44 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc.
- Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc.
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
### Fixes

View File

@@ -1,2 +1,2 @@
3c0423e26e758e7a5f5febcbaacd6a7ceb8584a8eecd0224f7ce98e6bcb9e9c0 plugin-sdk-api-baseline.json
952ba44c63a9f2107fc10aead1d0cc77ef06ac9a9befcac3ca9e4b0f4427cdfc plugin-sdk-api-baseline.jsonl
f8495c07213012748f099b12ddb02847ffd4eaa1b46f2ae9dfa574fa0ef3299a plugin-sdk-api-baseline.json
815ac868dda35d0af88b9c522233d6065c3eeb70775e19c111162b80390733fa plugin-sdk-api-baseline.jsonl

View File

@@ -414,6 +414,92 @@ describe("createCodexDynamicToolBridge", () => {
expect(result).toEqual(expectInputText("legacy compacted"));
});
it("keeps config out of Codex tool-result contexts", async () => {
const config = { session: { store: "/tmp/openclaw-session-store.json" } };
const registry = createEmptyPluginRegistry();
const middlewareContexts: Record<string, unknown>[] = [];
const legacyContexts: Record<string, unknown>[] = [];
const middleware = vi.fn(async (_event: unknown, ctx: Record<string, unknown>) => {
middlewareContexts.push(ctx);
return undefined;
});
const factory = async (codex: {
on: (
event: "tool_result",
handler: (
event: unknown,
ctx: Record<string, unknown>,
) => Promise<{ result: AgentToolResult<unknown> } | void>,
) => void;
}) => {
codex.on("tool_result", async (_event, ctx) => {
legacyContexts.push(ctx);
});
};
registry.agentToolResultMiddlewares.push({
pluginId: "tokenjuice",
pluginName: "Tokenjuice",
rawHandler: middleware,
handler: middleware,
runtimes: ["codex"],
source: "test",
});
registry.codexAppServerExtensionFactories.push({
pluginId: "legacy",
pluginName: "Legacy",
rawFactory: factory,
factory,
source: "test",
});
setActivePluginRegistry(registry);
const execute = vi.fn(async () => textToolResult("done"));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "exec", execute })],
signal: new AbortController().signal,
hookContext: {
agentId: "agent-1",
config: config as never,
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
},
});
await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "exec",
arguments: { command: "pwd" },
});
expect(execute).toHaveBeenCalledWith(
"call-1",
{ command: "pwd" },
expect.any(AbortSignal),
undefined,
);
expect(middlewareContexts).toHaveLength(1);
expect(middlewareContexts[0]).toMatchObject({
runtime: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
});
expect(middlewareContexts[0]).not.toHaveProperty("config");
expect(legacyContexts).toHaveLength(1);
expect(legacyContexts[0]).toMatchObject({
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
});
expect(legacyContexts[0]).not.toHaveProperty("config");
});
it("fires after_tool_call for successful codex tool executions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(

View File

@@ -6,6 +6,7 @@ import {
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
type EmbeddedRunAttemptParams,
isToolWrappedWithBeforeToolCallHook,
isMessagingTool,
isMessagingToolSendAction,
@@ -24,6 +25,16 @@ import {
type JsonValue,
} from "./protocol.js";
type CodexDynamicToolHookContext = {
agentId?: string;
config?: EmbeddedRunAttemptParams["config"];
sessionId?: string;
sessionKey?: string;
runId?: string;
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
export type CodexDynamicToolBridge = {
specs: CodexDynamicToolSpec[];
handleToolCall: (
@@ -45,13 +56,9 @@ export type CodexDynamicToolBridge = {
export function createCodexDynamicToolBridge(params: {
tools: AnyAgentTool[];
signal: AbortSignal;
hookContext?: {
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
};
hookContext?: CodexDynamicToolHookContext;
}): CodexDynamicToolBridge {
const toolResultHookContext = toToolResultHookContext(params.hookContext);
const tools = params.tools.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
@@ -68,11 +75,10 @@ export function createCodexDynamicToolBridge(params: {
};
const middlewareRunner = createAgentToolResultMiddlewareRunner({
runtime: "codex",
...params.hookContext,
...toolResultHookContext,
});
const legacyExtensionRunner = createCodexAppServerToolResultExtensionRunner(
params.hookContext ?? {},
);
const legacyExtensionRunner =
createCodexAppServerToolResultExtensionRunner(toolResultHookContext);
return {
specs: tools.map((tool) => ({
@@ -124,10 +130,10 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName: tool.name,
toolCallId: call.callId,
runId: params.hookContext?.runId,
agentId: params.hookContext?.agentId,
sessionId: params.hookContext?.sessionId,
sessionKey: params.hookContext?.sessionKey,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
startArgs: args,
result,
startedAt,
@@ -147,10 +153,10 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName: tool.name,
toolCallId: call.callId,
runId: params.hookContext?.runId,
agentId: params.hookContext?.agentId,
sessionId: params.hookContext?.sessionId,
sessionKey: params.hookContext?.sessionKey,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
startArgs: args,
error: error instanceof Error ? error.message : String(error),
startedAt,
@@ -169,6 +175,18 @@ export function createCodexDynamicToolBridge(params: {
};
}
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
const { agentId, sessionId, sessionKey, runId } = ctx ?? {};
return {
...(agentId && { agentId }),
...(sessionId && { sessionId }),
...(sessionKey && { sessionKey }),
...(runId && { runId }),
};
}
function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {

View File

@@ -425,6 +425,7 @@ export async function runCodexAppServerAttempt(
signal: runAbortController.signal,
hookContext: {
agentId: sessionAgentId,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
@@ -535,6 +536,7 @@ export async function runCodexAppServerAttempt(
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
config: params.config,
runId: params.runId,
signal: runAbortController.signal,
});
@@ -1376,6 +1378,7 @@ function createCodexNativeHookRelay(params: {
agentId: string | undefined;
sessionId: string;
sessionKey: string | undefined;
config: EmbeddedRunAttemptParams["config"];
runId: string;
signal: AbortSignal;
}): NativeHookRelayRegistrationHandle | undefined {
@@ -1392,6 +1395,7 @@ function createCodexNativeHookRelay(params: {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionId: params.sessionId,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.config ? { config: params.config } : {}),
runId: params.runId,
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
ttlMs: params.options?.ttlMs,

View File

@@ -1,11 +1,18 @@
import { statSync, writeFileSync } from "node:fs";
import fs from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js";
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
__testing,
buildNativeHookRelayCommand,
@@ -17,6 +24,7 @@ import {
afterEach(() => {
vi.useRealTimers();
resetGlobalHookRunner();
setActivePluginRegistry(createEmptyPluginRegistry());
__testing.clearNativeHookRelaysForTests();
});
@@ -629,6 +637,95 @@ describe("native hook relay registry", () => {
);
});
it("passes config to trusted policies for native pre-tool session extension reads", async () => {
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-native-relay-policy-"));
const storePath = path.join(stateDir, "sessions.json");
const config = { session: { store: storePath } };
const seen: unknown[] = [];
const registry = createEmptyPluginRegistry();
registry.sessionExtensions = [
{
pluginId: "policy-plugin",
pluginName: "Policy Plugin",
source: "test",
extension: {
namespace: "policy",
description: "policy state",
},
},
];
registry.trustedToolPolicies = [
{
pluginId: "policy-plugin",
pluginName: "Policy Plugin",
source: "test",
policy: {
id: "session-extension-policy",
description: "session extension policy",
evaluate(_event, ctx) {
const policyState = ctx.getSessionExtension?.("policy");
seen.push(policyState);
if ((policyState as { block?: boolean } | undefined)?.block) {
return { block: true, blockReason: "blocked by session extension" };
}
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
try {
await updateSessionStore(storePath, (store) => {
store["agent:main:session-1"] = {
sessionId: "session-1",
updatedAt: Date.now(),
} as SessionEntry;
});
await expect(
patchPluginSessionExtension({
cfg: config as never,
sessionKey: "agent:main:session-1",
pluginId: "policy-plugin",
namespace: "policy",
value: { block: true },
}),
).resolves.toMatchObject({ ok: true });
const relay = registerNativeHookRelay({
provider: "codex",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
config: config as never,
runId: "run-1",
allowedEvents: ["pre_tool_use"],
});
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: relay.relayId,
event: "pre_tool_use",
rawPayload: {
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_use_id: "native-policy-call-1",
tool_input: { command: "rm -rf dist" },
},
});
expect(JSON.parse(response.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "blocked by session extension",
},
});
expect(seen).toEqual([{ block: true }]);
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("does not rewrite Codex native tool input when before_tool_call adjusts params", async () => {
const beforeToolCall = vi.fn(async () => ({
params: { command: "echo replaced" },

View File

@@ -18,6 +18,7 @@ import {
} from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { PluginApprovalResolutions } from "../../plugins/types.js";
@@ -81,6 +82,7 @@ export type NativeHookRelayRegistration = {
agentId?: string;
sessionId: string;
sessionKey?: string;
config?: OpenClawConfig;
runId: string;
allowedEvents: readonly NativeHookRelayEvent[];
expiresAtMs: number;
@@ -98,6 +100,7 @@ export type RegisterNativeHookRelayParams = {
agentId?: string;
sessionId: string;
sessionKey?: string;
config?: OpenClawConfig;
runId: string;
allowedEvents?: readonly NativeHookRelayEvent[];
ttlMs?: number;
@@ -299,6 +302,7 @@ export function registerNativeHookRelay(
...(params.agentId ? { agentId: params.agentId } : {}),
sessionId: params.sessionId,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.config ? { config: params.config } : {}),
runId: params.runId,
allowedEvents,
expiresAtMs: Date.now() + normalizePositiveInteger(params.ttlMs, DEFAULT_RELAY_TTL_MS),
@@ -878,6 +882,7 @@ async function runNativeHookRelayPreToolUse(params: {
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
sessionId: params.registration.sessionId,
...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}),
...(params.registration.config ? { config: params.registration.config } : {}),
runId: params.registration.runId,
},
});

View File

@@ -1622,6 +1622,7 @@ export async function runEmbeddedAttempt(
{
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
config: params.config,
sessionId: params.sessionId,
runId: params.runId,
loopDetection: clientToolLoopDetection,

View File

@@ -1,11 +1,17 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { updateSessionStore, type SessionEntry } from "../config/sessions.js";
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { addTestHook, createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { patchPluginSessionExtension } from "../plugins/host-hook-state.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { PluginHookRegistration } from "../plugins/types.js";
type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js");
@@ -451,4 +457,84 @@ describe("before_tool_call hook integration for client tools", () => {
{ value: "second", marker: "second_tool" },
]);
});
it("lets trusted policies read session extensions for client tools when config is provided", async () => {
resetGlobalHookRunner();
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-client-tool-policy-"));
const storePath = path.join(stateDir, "sessions.json");
const config = { session: { store: storePath } };
const seen: unknown[] = [];
const registry = createEmptyPluginRegistry();
registry.sessionExtensions = [
{
pluginId: "policy-plugin",
pluginName: "Policy Plugin",
source: "test",
extension: {
namespace: "policy",
description: "policy state",
},
},
];
registry.trustedToolPolicies = [
{
pluginId: "policy-plugin",
pluginName: "Policy Plugin",
source: "test",
policy: {
id: "client-tool-session-extension-policy",
description: "client tool session extension policy",
evaluate(_event, ctx) {
seen.push(ctx.getSessionExtension?.("policy"));
return undefined;
},
},
},
];
setActivePluginRegistry(registry);
try {
await updateSessionStore(storePath, (store) => {
store["agent:main:client"] = {
sessionId: "session-client",
updatedAt: Date.now(),
} as SessionEntry;
});
await expect(
patchPluginSessionExtension({
cfg: config as never,
sessionKey: "agent:main:client",
pluginId: "policy-plugin",
namespace: "policy",
value: { gate: "client" },
}),
).resolves.toMatchObject({ ok: true });
const [tool] = toClientToolDefinitions(
[
{
type: "function",
function: {
name: "client_tool",
description: "Client tool",
parameters: { type: "object", properties: {} },
},
},
],
undefined,
{
agentId: "main",
sessionKey: "agent:main:client",
sessionId: "session-client",
config: config as never,
},
);
const extensionContext = {} as Parameters<typeof tool.execute>[4];
await tool.execute("client-call-policy", {}, undefined, undefined, extensionContext);
expect(seen).toEqual([{ gate: "client" }]);
} finally {
setActivePluginRegistry(createEmptyPluginRegistry());
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import {
diagnosticErrorCategory,
@@ -31,6 +32,7 @@ import { callGatewayTool } from "./tools/gateway.js";
export type HookContext = {
agentId?: string;
config?: OpenClawConfig;
sessionKey?: string;
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
@@ -491,6 +493,7 @@ export async function runBeforeToolCallHook(args: {
...(args.toolCallId && { toolCallId: args.toolCallId }),
},
toolContext,
args.ctx?.config ? { config: args.ctx.config } : undefined,
);
if (trustedPolicyResult?.block) {
return {

View File

@@ -832,6 +832,7 @@ export function createOpenClawCodingTools(options?: {
const withHooks = normalized.map((tool) =>
wrapToolWithBeforeToolCallHook(tool, {
agentId,
...(options?.config ? { config: options.config } : {}),
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,

View File

@@ -165,6 +165,8 @@ export type SessionEntry = {
heartbeatTaskState?: Record<string, number>;
/** Plugin-owned session state, grouped by plugin id then extension namespace. */
pluginExtensions?: Record<string, Record<string, SessionPluginJsonValue>>;
/** Top-level SessionEntry mirror slots owned by plugin session extensions. */
pluginExtensionSlotKeys?: Record<string, Record<string, string>>;
/** Durable one-shot prompt additions drained before the next agent turn. */
pluginNextTurnInjections?: Record<string, SessionPluginNextTurnInjection[]>;
sessionId: string;

View File

@@ -43,7 +43,7 @@ const resolveGatewayScopedToolsMock = vi.hoisted(() =>
})),
);
vi.mock("../config/config.js", () => ({
vi.mock("../config/io.js", () => ({
getRuntimeConfig: () => ({ session: { mainKey: "main" } }),
}));
@@ -452,6 +452,7 @@ describe("mcp loopback server", () => {
params: { body: "hello" },
ctx: expect.objectContaining({
agentId: "main",
config: { session: { mainKey: "main" } },
sessionKey: "agent:main:main",
}),
signal: expect.any(AbortSignal),

View File

@@ -132,6 +132,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
toolSchema: scopedTools.toolSchema,
hookContext: {
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
},
signal: requestAbort.signal,

View File

@@ -447,6 +447,7 @@ describe("POST /tools/invoke", () => {
toolName: "agents_list",
ctx: expect.objectContaining({
agentId: "main",
config: cfg,
sessionKey: "agent:main:main",
loopDetection: { warnAt: 3 },
}),
@@ -995,6 +996,7 @@ describe("tools.invoke Gateway RPC", () => {
toolCallId: "rpc-rpc-tool-test",
ctx: expect.objectContaining({
agentId: "main",
config: cfg,
sessionKey: "agent:main:main",
}),
}),

View File

@@ -254,6 +254,7 @@ export async function invokeGatewayTool(params: {
toolCallId,
ctx: {
agentId,
config: params.cfg,
sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
},

View File

@@ -56,11 +56,15 @@ function createTrustedBundledPluginsRoot(kind: "dist" | "dist-runtime" = "dist")
return rootDir;
}
function writeFixturePackageJson(pluginRoot: string, pluginId: string): void {
function writeFixturePackageJson(
pluginRoot: string,
pluginId: string,
type: "commonjs" | "module" = "module",
): void {
writeJsonFile(path.join(pluginRoot, "package.json"), {
name: `@openclaw/${pluginId}`,
version: "0.0.0",
type: "module",
type,
});
}
@@ -108,7 +112,7 @@ function createThrowingPluginFixture(prefix: string): TrustedBundledPluginFixtur
const pluginRoot = path.join(bundledPluginsDir, pluginId);
fs.mkdirSync(pluginRoot, { recursive: true });
trustedBundledPluginFixtureRoots.push(pluginRoot);
writeFixturePackageJson(pluginRoot, pluginId);
writeFixturePackageJson(pluginRoot, pluginId, "commonjs");
fs.writeFileSync(
path.join(pluginRoot, "api.js"),
'throw new Error("plugin load failure");\n',

View File

@@ -36,11 +36,15 @@ function createTrustedBundledFixtureRoot(prefix: string): string {
return rootDir;
}
function writePluginPackageJson(pluginDir: string, name = "demo"): void {
function writePluginPackageJson(
pluginDir: string,
name = "demo",
type: "commonjs" | "module" = "module",
): void {
writeJsonFile(path.join(pluginDir, "package.json"), {
name: `@openclaw/plugin-${name}`,
version: "0.0.0",
type: "module",
type,
});
}
@@ -66,7 +70,7 @@ function createThrowingPluginDir(prefix: string): string {
const rootDir = createTrustedBundledFixtureRoot(prefix);
const pluginDir = path.join(rootDir, "bad");
fs.mkdirSync(pluginDir, { recursive: true });
writePluginPackageJson(pluginDir, "bad");
writePluginPackageJson(pluginDir, "bad", "commonjs");
fs.writeFileSync(
path.join(pluginDir, "api.js"),
`throw new Error("plugin load failure");\n`,

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ import type {
PluginHookMessageSendingResult,
PluginHookMessageSentEvent,
} from "./hook-message.types.js";
import type { PluginJsonValue } from "./host-hook-json.js";
import type {
PluginAgentTurnPrepareEvent,
PluginAgentTurnPrepareResult,
@@ -403,6 +404,10 @@ export type PluginHookToolContext = {
trace?: DiagnosticTraceContext;
toolName: string;
toolCallId?: string;
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Plugin callers type JSON reads by namespace.
getSessionExtension?: <T extends PluginJsonValue = PluginJsonValue>(
namespace: string,
) => T | undefined;
};
export type PluginHookBeforeToolCallEvent = {

View File

@@ -13,6 +13,8 @@ import {
} from "./host-hook-runtime.js";
import type { PluginHostCleanupReason } from "./host-hooks.js";
import type { PluginRegistry } from "./registry-types.js";
import { getActivePluginRegistry } from "./runtime.js";
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
export type PluginHostCleanupFailure = {
pluginId: string;
@@ -29,9 +31,98 @@ function shouldCleanPlugin(pluginId: string, filterPluginId?: string): boolean {
return !filterPluginId || pluginId === filterPluginId;
}
export function clearPluginOwnedSessionState(entry: SessionEntry, pluginId?: string): void {
function collectStoredSessionEntrySlotKeys(entry: SessionEntry, pluginId?: string): Set<string> {
const slotKeys = new Set<string>();
const storedSlotKeys = entry.pluginExtensionSlotKeys;
if (!storedSlotKeys) {
return slotKeys;
}
const records =
pluginId === undefined
? Object.values(storedSlotKeys)
: storedSlotKeys[pluginId]
? [storedSlotKeys[pluginId]]
: [];
for (const record of records) {
for (const slotKey of Object.values(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok) {
slotKeys.add(normalized.key);
}
}
}
return slotKeys;
}
function collectPromotedSessionEntrySlotKeys(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): Set<string> {
const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId);
for (const slotKey of sessionEntrySlotKeys ?? []) {
slotKeys.add(slotKey);
}
return slotKeys;
}
function clearPromotedSessionEntrySlots(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
options: { includeStoredSlotKeys?: boolean; pruneSlotOwnership?: boolean } = {},
): void {
const slotKeys =
options.includeStoredSlotKeys === false && sessionEntrySlotKeys
? new Set(sessionEntrySlotKeys)
: collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
delete entryRecord[slotKey];
}
if (!options.pruneSlotOwnership || !entry.pluginExtensionSlotKeys) {
return;
}
const pruneRecord = (record: Record<string, string>): void => {
for (const [namespace, slotKey] of Object.entries(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok && slotKeys.has(normalized.key)) {
delete record[namespace];
}
}
};
if (pluginId) {
const record = entry.pluginExtensionSlotKeys[pluginId];
if (record) {
pruneRecord(record);
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[pluginId];
}
}
} else {
for (const record of Object.values(entry.pluginExtensionSlotKeys)) {
pruneRecord(record);
}
for (const [ownerPluginId, record] of Object.entries(entry.pluginExtensionSlotKeys)) {
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[ownerPluginId];
}
}
}
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
export function clearPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): void {
clearPromotedSessionEntrySlots(entry, pluginId, sessionEntrySlotKeys);
if (!pluginId) {
delete entry.pluginExtensions;
delete entry.pluginExtensionSlotKeys;
delete entry.pluginNextTurnInjections;
return;
}
@@ -41,6 +132,12 @@ export function clearPluginOwnedSessionState(entry: SessionEntry, pluginId?: str
delete entry.pluginExtensions;
}
}
if (entry.pluginExtensionSlotKeys) {
delete entry.pluginExtensionSlotKeys[pluginId];
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
if (entry.pluginNextTurnInjections) {
delete entry.pluginNextTurnInjections[pluginId];
if (Object.keys(entry.pluginNextTurnInjections).length === 0) {
@@ -49,11 +146,42 @@ export function clearPluginOwnedSessionState(entry: SessionEntry, pluginId?: str
}
}
function hasPluginOwnedSessionState(entry: SessionEntry, pluginId?: string): boolean {
if (!pluginId) {
return Boolean(entry.pluginExtensions || entry.pluginNextTurnInjections);
function hasPromotedSessionEntrySlot(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
if (slotKeys.size === 0) {
return false;
}
return Boolean(entry.pluginExtensions?.[pluginId] || entry.pluginNextTurnInjections?.[pluginId]);
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
if (Object.prototype.hasOwnProperty.call(entryRecord, slotKey)) {
return true;
}
}
return false;
}
function hasPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
if (hasPromotedSessionEntrySlot(entry, pluginId, sessionEntrySlotKeys)) {
return true;
}
if (!pluginId) {
return Boolean(
entry.pluginExtensions || entry.pluginExtensionSlotKeys || entry.pluginNextTurnInjections,
);
}
return Boolean(
entry.pluginExtensions?.[pluginId] ||
entry.pluginExtensionSlotKeys?.[pluginId] ||
entry.pluginNextTurnInjections?.[pluginId],
);
}
function matchesCleanupSession(
@@ -75,6 +203,7 @@ async function clearPluginOwnedSessionStores(params: {
cfg: OpenClawConfig;
pluginId?: string;
sessionKey?: string;
sessionEntrySlotKeys?: ReadonlySet<string>;
}): Promise<number> {
if (!params.pluginId && !params.sessionKey) {
return 0;
@@ -92,11 +221,11 @@ async function clearPluginOwnedSessionStores(params: {
for (const [entryKey, entry] of Object.entries(store)) {
if (
!matchesCleanupSession(entryKey, entry, params.sessionKey) ||
!hasPluginOwnedSessionState(entry, params.pluginId)
!hasPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys)
) {
continue;
}
clearPluginOwnedSessionState(entry, params.pluginId);
clearPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys);
entry.updatedAt = now;
clearedInStore += 1;
}
@@ -106,6 +235,66 @@ async function clearPluginOwnedSessionStores(params: {
return cleared;
}
async function clearPromotedSessionEntrySlotStores(params: {
cfg: OpenClawConfig;
pluginId?: string;
sessionKey?: string;
sessionEntrySlotKeys: ReadonlySet<string>;
}): Promise<number> {
if ((!params.pluginId && !params.sessionKey) || params.sessionEntrySlotKeys.size === 0) {
return 0;
}
const storePaths = new Set(
resolveAllAgentSessionStoreTargetsSync(params.cfg)
.map((target) => target.storePath)
.filter((storePath) => fs.existsSync(storePath)),
);
let cleared = 0;
for (const storePath of storePaths) {
cleared += await updateSessionStore(storePath, (store) => {
let clearedInStore = 0;
const now = Date.now();
for (const [entryKey, entry] of Object.entries(store)) {
if (
!matchesCleanupSession(entryKey, entry, params.sessionKey) ||
!hasPromotedSessionEntrySlot(entry, params.pluginId, params.sessionEntrySlotKeys)
) {
continue;
}
clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys, {
includeStoredSlotKeys: false,
pruneSlotOwnership: true,
});
entry.updatedAt = now;
clearedInStore += 1;
}
return clearedInStore;
});
}
return cleared;
}
function collectSessionEntrySlotKeys(
registry: PluginRegistry | null | undefined,
pluginId?: string,
): Set<string> {
const slotKeys = new Set<string>();
for (const registration of registry?.sessionExtensions ?? []) {
if (!shouldCleanPlugin(registration.pluginId, pluginId)) {
continue;
}
const slotKey = registration.extension.sessionEntrySlotKey;
if (slotKey === undefined) {
continue;
}
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok) {
slotKeys.add(normalized.key);
}
}
return slotKeys;
}
export async function runPluginHostCleanup(params: {
cfg?: OpenClawConfig;
registry?: PluginRegistry | null;
@@ -115,20 +304,37 @@ export async function runPluginHostCleanup(params: {
runId?: string;
preserveSchedulerJobIds?: ReadonlySet<string>;
shouldCleanup?: () => boolean;
restartPromotedSessionEntrySlotKeys?: ReadonlySet<string>;
}): Promise<PluginHostCleanupResult> {
const failures: PluginHostCleanupFailure[] = [];
const shouldCleanup = params.shouldCleanup ?? (() => true);
if (!shouldCleanup()) {
return { cleanupCount: 0, failures };
}
const registry = params.registry;
const sessionEntrySlotKeys = collectSessionEntrySlotKeys(
registry ?? getActivePluginRegistry(),
params.pluginId,
);
const restartPromotedSessionEntrySlotKeys =
params.restartPromotedSessionEntrySlotKeys ?? sessionEntrySlotKeys;
let persistentCleanupCount = 0;
if (params.reason !== "restart" && shouldCleanup()) {
if (shouldCleanup()) {
try {
persistentCleanupCount = await clearPluginOwnedSessionStores({
cfg: params.cfg ?? getRuntimeConfig(),
pluginId: params.pluginId,
sessionKey: params.sessionKey,
});
persistentCleanupCount =
params.reason === "restart"
? await clearPromotedSessionEntrySlotStores({
cfg: params.cfg ?? getRuntimeConfig(),
pluginId: params.pluginId,
sessionKey: params.sessionKey,
sessionEntrySlotKeys: restartPromotedSessionEntrySlotKeys,
})
: await clearPluginOwnedSessionStores({
cfg: params.cfg ?? getRuntimeConfig(),
pluginId: params.pluginId,
sessionKey: params.sessionKey,
sessionEntrySlotKeys,
});
} catch (error) {
failures.push({
pluginId: params.pluginId ?? "plugin-host",
@@ -137,7 +343,6 @@ export async function runPluginHostCleanup(params: {
});
}
}
const registry = params.registry;
let cleanupCount = persistentCleanupCount;
if (registry) {
for (const registration of registry.sessionExtensions ?? []) {
@@ -279,6 +484,19 @@ function collectSchedulerJobIds(
);
}
function collectRestartPromotedSessionEntrySlotKeys(
previousRegistry: PluginRegistry,
nextRegistry: PluginRegistry | null | undefined,
pluginId: string,
): Set<string> {
const staleSlotKeys = collectSessionEntrySlotKeys(previousRegistry, pluginId);
const preservedSlotKeys = collectSessionEntrySlotKeys(nextRegistry, pluginId);
for (const slotKey of preservedSlotKeys) {
staleSlotKeys.delete(slotKey);
}
return staleSlotKeys;
}
export async function cleanupReplacedPluginHostRegistry(params: {
cfg: OpenClawConfig;
previousRegistry?: PluginRegistry | null;
@@ -313,6 +531,13 @@ export async function cleanupReplacedPluginHostRegistry(params: {
? collectSchedulerJobIds(params.nextRegistry, pluginId)
: undefined,
shouldCleanup,
restartPromotedSessionEntrySlotKeys: restarted
? collectRestartPromotedSessionEntrySlotKeys(
previousRegistry,
params.nextRegistry,
pluginId,
)
: undefined,
});
cleanupCount += result.cleanupCount;
failures.push(...result.failures);

View File

@@ -26,8 +26,10 @@ import {
type PluginNextTurnInjectionEnqueueResult,
type PluginNextTurnInjectionRecord,
type PluginSessionExtensionProjection,
type PluginSessionExtensionRegistration,
} from "./host-hooks.js";
import { getActivePluginRegistry } from "./runtime.js";
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
const log = createSubsystemLogger("plugins/host-hook-state");
const PROJECTION_FAILED = Symbol("plugin-session-extension-projection-failed");
@@ -395,6 +397,43 @@ export async function drainPluginNextTurnInjectionContext(params: {
};
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Session-extension JSON reads are caller-typed by namespace.
export function getPluginSessionExtensionSync<T extends PluginJsonValue = PluginJsonValue>(params: {
cfg: OpenClawConfig;
pluginId: string;
sessionKey?: string;
namespace: string;
}): T | undefined {
const pluginId = params.pluginId.trim();
const sessionKey = normalizeOptionalString(params.sessionKey);
const namespace = normalizeNamespace(params.namespace);
if (!pluginId || !sessionKey || !namespace) {
return undefined;
}
const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey });
const value = loaded.entry?.pluginExtensions?.[pluginId]?.[namespace] as
| PluginJsonValue
| undefined;
return value as T | undefined;
}
export function getPluginSessionExtensionStateSync(params: {
cfg: OpenClawConfig;
pluginId: string;
sessionKey?: string;
}): Record<string, PluginJsonValue> | undefined {
const pluginId = params.pluginId.trim();
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!pluginId || !sessionKey) {
return undefined;
}
const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey });
const value = loaded.entry?.pluginExtensions?.[pluginId] as
| Record<string, PluginJsonValue>
| undefined;
return value ? (copyJsonValue(value) as Record<string, PluginJsonValue>) : undefined;
}
export async function patchPluginSessionExtension(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -419,10 +458,10 @@ export async function patchPluginSessionExtension(params: {
}
const nextPluginValue = params.value as PluginJsonValue;
const registry = getActivePluginRegistry();
const registered = (registry?.sessionExtensions ?? []).some(
const registration = (registry?.sessionExtensions ?? []).find(
(entry) => entry.pluginId === pluginId && entry.extension.namespace === namespace,
);
if (!registered) {
if (!registration) {
return { ok: false, error: `unknown plugin session extension: ${pluginId}/${namespace}` };
}
const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey: params.sessionKey });
@@ -430,11 +469,24 @@ export async function patchPluginSessionExtension(params: {
return { ok: false, error: `unknown session key: ${params.sessionKey}` };
}
const canonicalKey = loaded.canonicalKey ?? params.sessionKey;
// Promote the projected value into a top-level SessionEntry slot when the
// extension opted in via `sessionEntrySlotKey`. The slot is a read-only
// mirror: writes still go through patchSessionExtension; the host overwrites
// the slot value on every patch and clears it on unset.
const rawSlotKey = normalizeOptionalString(registration.extension.sessionEntrySlotKey);
const normalizedSlotKey = rawSlotKey ? normalizeSessionEntrySlotKey(rawSlotKey) : undefined;
if (normalizedSlotKey?.ok === false) {
log.warn(
`plugin session extension slot promotion skipped for ${pluginId}/${namespace}: ${normalizedSlotKey.error}`,
);
}
const slotKey = normalizedSlotKey?.ok === true ? normalizedSlotKey.key : undefined;
const nextValue = await updateSessionStore(loaded.storePath, (store) => {
const entry = store[loaded.storeKey];
if (!entry) {
return undefined;
}
const entryRecord = entry as Record<string, unknown>;
const pluginExtensions = { ...entry.pluginExtensions };
const pluginState = { ...pluginExtensions[pluginId] };
if (params.unset === true) {
@@ -452,12 +504,81 @@ export async function patchPluginSessionExtension(params: {
} else {
delete entry.pluginExtensions;
}
const storedSlotKeys = { ...entry.pluginExtensionSlotKeys };
const pluginSlotKeys = { ...storedSlotKeys[pluginId] };
const previousSlotKey = normalizeSessionEntrySlotKey(pluginSlotKeys[namespace]);
if (previousSlotKey.ok && previousSlotKey.key !== slotKey) {
delete entryRecord[previousSlotKey.key];
}
if (slotKey && params.unset !== true) {
pluginSlotKeys[namespace] = slotKey;
} else {
delete pluginSlotKeys[namespace];
}
if (Object.keys(pluginSlotKeys).length > 0) {
storedSlotKeys[pluginId] = pluginSlotKeys;
} else {
delete storedSlotKeys[pluginId];
}
if (Object.keys(storedSlotKeys).length > 0) {
entry.pluginExtensionSlotKeys = storedSlotKeys;
} else {
delete entry.pluginExtensionSlotKeys;
}
if (slotKey) {
const projected = projectSessionExtensionValueForSlot({
registration,
sessionKey: canonicalKey,
sessionId: entry.sessionId,
nextValue: params.unset === true ? undefined : nextPluginValue,
});
if (projected === undefined) {
delete entryRecord[slotKey];
} else {
entryRecord[slotKey] = projected;
}
}
entry.updatedAt = Date.now();
return pluginState[namespace] as PluginJsonValue | undefined;
});
return { ok: true, key: canonicalKey, value: nextValue };
}
/**
* Resolve the value that should be mirrored to `SessionEntry[slotKey]` for a
* promoted session-extension namespace. Failures are swallowed so a
* misbehaving projector cannot block the primary patch from being persisted.
*/
function projectSessionExtensionValueForSlot(params: {
registration: { pluginId: string; extension: PluginSessionExtensionRegistration };
sessionKey: string;
sessionId?: string;
nextValue: PluginJsonValue | undefined;
}): PluginJsonValue | undefined {
if (params.nextValue === undefined) {
return undefined;
}
const projected = projectSessionExtensionValue({
pluginId: params.registration.pluginId,
namespace: params.registration.extension.namespace,
project: params.registration.extension.project,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
state: params.nextValue,
});
if (projected === PROJECTION_FAILED) {
return undefined;
}
if (isPromiseLike(projected)) {
discardUnexpectedPromiseProjection(projected);
return undefined;
}
if (projected === undefined || !isPluginJsonValue(projected)) {
return undefined;
}
return copyJsonValue(projected);
}
export async function projectPluginSessionExtensions(params: {
sessionKey: string;
entry: SessionEntry;

View File

@@ -39,6 +39,23 @@ export type PluginSessionExtensionRegistration = {
description: string;
project?: (ctx: PluginSessionExtensionProjectionContext) => PluginJsonValue | undefined;
cleanup?: (ctx: { reason: PluginHostCleanupReason; sessionKey?: string }) => void | Promise<void>;
/**
* When set, after every successful `patchSessionExtension` the projected
* value is mirrored to `SessionEntry[<slotKey>]` so non-plugin readers
* can consume the typed slot without reaching into
* `pluginExtensions[pluginId][namespace]`.
*
* The slot is a read-only mirror: writes always go through
* `patchSessionExtension`; the host overwrites the slot value on every
* subsequent patch.
*/
sessionEntrySlotKey?: string;
/**
* Optional JSON-compatible schema describing the projected slot value.
* Purely informational at this layer; clients may use it to validate the
* mirrored slot against a contract.
*/
sessionEntrySlotSchema?: PluginJsonValue;
};
export type PluginSessionExtensionProjection = {

View File

@@ -126,6 +126,7 @@ import type {
} from "./registry-types.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import {
findUndeclaredPluginToolNames,
@@ -1625,6 +1626,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const namespace = normalizeHostHookString(extension.namespace);
const description = normalizeHostHookString(extension.description);
const project = extension.project;
let normalizedSessionEntrySlotKey: string | undefined;
let invalidMessage: string | undefined;
if (!namespace || !description) {
invalidMessage = "session extension registration requires namespace and description";
@@ -1634,6 +1636,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
invalidMessage = "session extension projector must be synchronous";
} else if (extension.cleanup !== undefined && typeof extension.cleanup !== "function") {
invalidMessage = "session extension cleanup must be a function";
} else if (extension.sessionEntrySlotKey !== undefined) {
const slotKey = normalizeSessionEntrySlotKey(extension.sessionEntrySlotKey);
if (!slotKey.ok) {
invalidMessage = slotKey.error;
} else {
normalizedSessionEntrySlotKey = slotKey.key;
}
}
if (invalidMessage) {
pushDiagnostic({
@@ -1656,6 +1665,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (normalizedSessionEntrySlotKey) {
const existingSlot = (registry.sessionExtensions ?? []).find((entry) => {
const existingSlotKey = entry.extension.sessionEntrySlotKey;
if (existingSlotKey === undefined) {
return false;
}
const normalizedExistingSlotKey = normalizeSessionEntrySlotKey(existingSlotKey);
return (
normalizedExistingSlotKey.ok &&
normalizedExistingSlotKey.key === normalizedSessionEntrySlotKey
);
});
if (existingSlot) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `sessionEntrySlotKey already registered: ${normalizedSessionEntrySlotKey}`,
});
return;
}
}
(registry.sessionExtensions ??= []).push({
pluginId: record.id,
pluginName: record.name,
@@ -1663,6 +1694,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
...extension,
namespace,
description,
...(normalizedSessionEntrySlotKey
? { sessionEntrySlotKey: normalizedSessionEntrySlotKey }
: {}),
},
source: record.source,
rootDir: record.rootDir,

View File

@@ -0,0 +1,152 @@
import type { SessionEntry } from "../config/sessions/types.js";
const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"__proto__",
"constructor",
"prototype",
"lastHeartbeatText",
"lastHeartbeatSentAt",
"heartbeatIsolatedBaseSessionKey",
"heartbeatTaskState",
"pluginExtensions",
"pluginExtensionSlotKeys",
"pluginNextTurnInjections",
"sessionId",
"updatedAt",
"sessionFile",
"spawnedBy",
"spawnedWorkspaceDir",
"parentSessionKey",
"forkedFromParent",
"spawnDepth",
"subagentRole",
"subagentControlScope",
"subagentRecovery",
"pluginOwnerId",
"systemSent",
"abortedLastRun",
"sessionStartedAt",
"lastInteractionAt",
"startedAt",
"endedAt",
"runtimeMs",
"status",
"abortCutoffMessageSid",
"abortCutoffTimestamp",
"chatType",
"thinkingLevel",
"fastMode",
"verboseLevel",
"traceLevel",
"reasoningLevel",
"elevatedLevel",
"ttsAuto",
"lastTtsReadLatestHash",
"lastTtsReadLatestAt",
"execHost",
"execSecurity",
"execAsk",
"execNode",
"responseUsage",
"providerOverride",
"modelOverride",
"agentRuntimeOverride",
"modelOverrideSource",
"authProfileOverride",
"authProfileOverrideSource",
"authProfileOverrideCompactionCount",
"liveModelSwitchPending",
"groupActivation",
"groupActivationNeedsSystemIntro",
"sendPolicy",
"queueMode",
"queueDebounceMs",
"queueCap",
"queueDrop",
"inputTokens",
"outputTokens",
"totalTokens",
"totalTokensFresh",
"estimatedCostUsd",
"cacheRead",
"cacheWrite",
"modelProvider",
"model",
"agentHarnessId",
"fallbackNoticeSelectedModel",
"fallbackNoticeActiveModel",
"fallbackNoticeReason",
"contextTokens",
"compactionCount",
"compactionCheckpoints",
"memoryFlushAt",
"memoryFlushCompactionCount",
"memoryFlushContextHash",
"cliSessionIds",
"cliSessionBindings",
"claudeCliSessionId",
"label",
"displayName",
"channel",
"groupId",
"subject",
"groupChannel",
"space",
"origin",
"deliveryContext",
"lastChannel",
"lastTo",
"lastAccountId",
"lastThreadId",
"skillsSnapshot",
"systemPromptReport",
"pluginDebugEntries",
"acp",
] as const satisfies ReadonlyArray<keyof SessionEntry | "__proto__" | "constructor" | "prototype">;
type ReservedSessionEntrySlotKey = Extract<
(typeof SESSION_ENTRY_RESERVED_SLOT_KEY_LIST)[number],
keyof SessionEntry
>;
type MissingSessionEntryReservedSlotKeys = Exclude<keyof SessionEntry, ReservedSessionEntrySlotKey>;
type AssertNever<T extends never> = T;
type _AssertAllSessionEntryKeysAreReserved = AssertNever<MissingSessionEntryReservedSlotKeys>;
const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set<string>(SESSION_ENTRY_RESERVED_SLOT_KEY_LIST);
const OBJECT_PROTOTYPE_RESERVED_SLOT_KEYS = new Set<string>([
"prototype",
...Object.getOwnPropertyNames(Object.prototype),
]);
const SESSION_ENTRY_SLOT_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/u;
export function normalizeSessionEntrySlotKey(
value: unknown,
): { ok: true; key: string } | { ok: false; error: string } {
if (typeof value !== "string") {
return { ok: false, error: "sessionEntrySlotKey must be a string" };
}
const key = value.trim();
if (!key) {
return { ok: false, error: "sessionEntrySlotKey cannot be empty" };
}
if (!SESSION_ENTRY_SLOT_KEY_RE.test(key)) {
return {
ok: false,
error: "sessionEntrySlotKey must be an identifier-style field name",
};
}
if (SESSION_ENTRY_RESERVED_SLOT_KEYS.has(key)) {
return {
ok: false,
error: `sessionEntrySlotKey is reserved by SessionEntry: ${key}`,
};
}
if (OBJECT_PROTOTYPE_RESERVED_SLOT_KEYS.has(key)) {
return {
ok: false,
error: `sessionEntrySlotKey is reserved by Object: ${key}`,
};
}
return { ok: true, key };
}

View File

@@ -1,20 +1,68 @@
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type {
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookToolContext,
} from "./hook-types.js";
import { getPluginSessionExtensionStateSync } from "./host-hook-state.js";
import type { PluginJsonValue } from "./host-hooks.js";
import { getActivePluginRegistry } from "./runtime.js";
export async function runTrustedToolPolicies(
event: PluginHookBeforeToolCallEvent,
ctx: PluginHookToolContext,
options?: { config?: OpenClawConfig },
): Promise<PluginHookBeforeToolCallResult | undefined> {
const policies = getActivePluginRegistry()?.trustedToolPolicies ?? [];
let adjustedParams = event.params;
let hasAdjustedParams = false;
let approval: PluginHookBeforeToolCallResult["requireApproval"];
const sessionExtensionStateCache = new Map<string, Record<string, PluginJsonValue> | undefined>();
let resolvedSessionConfig: OpenClawConfig | undefined = options?.config;
let didResolveSessionConfig = Boolean(options?.config);
const resolveSessionConfig = (): OpenClawConfig | undefined => {
if (!didResolveSessionConfig) {
didResolveSessionConfig = true;
try {
resolvedSessionConfig = getRuntimeConfig();
} catch {
resolvedSessionConfig = undefined;
}
}
return resolvedSessionConfig;
};
for (const registration of policies) {
const decision = await registration.policy.evaluate({ ...event, params: adjustedParams }, ctx);
const policyCtx: PluginHookToolContext = {
...ctx,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Plugin callers type JSON reads by namespace.
getSessionExtension: <T extends PluginJsonValue = PluginJsonValue>(namespace: string) => {
const normalizedNamespace = namespace.trim();
const cacheKey = registration.pluginId;
if (!sessionExtensionStateCache.has(cacheKey)) {
const config = ctx.sessionKey ? resolveSessionConfig() : undefined;
sessionExtensionStateCache.set(
cacheKey,
config
? getPluginSessionExtensionStateSync({
cfg: config,
pluginId: registration.pluginId,
sessionKey: ctx.sessionKey,
})
: undefined,
);
}
const pluginState = sessionExtensionStateCache.get(cacheKey);
if (!normalizedNamespace || !pluginState) {
return undefined;
}
return pluginState[normalizedNamespace] as T | undefined;
},
};
const decision = await registration.policy.evaluate(
{ ...event, params: adjustedParams },
policyCtx,
);
if (!decision) {
continue;
}

View File

@@ -117,13 +117,16 @@ describe("production lint suppressions", () => {
"src/plugin-sdk/test-helpers/package-manifest-contract.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugin-sdk/test-helpers/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugin-sdk/test-helpers/subagent-hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/hook-types.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/host-hook-runtime.ts|typescript/no-unnecessary-type-parameters|2",
"src/plugins/host-hook-state.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/host-hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/runtime/runtime-plugin-boundary.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/runtime/types-channel.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/trusted-tool-policy.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/types.ts|typescript/no-unnecessary-type-parameters|1",
"src/tasks/task-flow-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1",
"src/tasks/task-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1",