diff --git a/CHANGELOG.md b/CHANGELOG.md index a05917182f4..e9378424dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a4cba731222..95673ce3b0b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 7519d981932..14460326d06 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -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[] = []; + const legacyContexts: Record[] = []; + const middleware = vi.fn(async (_event: unknown, ctx: Record) => { + middlewareContexts.push(ctx); + return undefined; + }); + const factory = async (codex: { + on: ( + event: "tool_result", + handler: ( + event: unknown, + ctx: Record, + ) => Promise<{ result: AgentToolResult } | 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( diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 64a36d500a4..285fe2979e2 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -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; + 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 { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); if (activeSignals.length === 0) { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index cfdd72e417b..6f195eaf368 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -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, diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 16f8583b7b8..df00a3e5ea4 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -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" }, diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index 38de9875cab..dd78198f9d8 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -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, }, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a8ab0ac9bc4..374a4d50710 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1622,6 +1622,7 @@ export async function runEmbeddedAttempt( { agentId: sessionAgentId, sessionKey: sandboxSessionKey, + config: params.config, sessionId: params.sessionId, runId: params.runId, loopDetection: clientToolLoopDetection, diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 4a19b79ecdf..398e1fc9352 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -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[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 }); + } + }); }); diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 709aef74716..86e2b119469 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -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 { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 451efa8a6e7..69f109cfe38 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index ab042ca5812..fcffc810ab6 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -165,6 +165,8 @@ export type SessionEntry = { heartbeatTaskState?: Record; /** Plugin-owned session state, grouped by plugin id then extension namespace. */ pluginExtensions?: Record>; + /** Top-level SessionEntry mirror slots owned by plugin session extensions. */ + pluginExtensionSlotKeys?: Record>; /** Durable one-shot prompt additions drained before the next agent turn. */ pluginNextTurnInjections?: Record; sessionId: string; diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index b053f507c24..b4606c9404c 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -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), diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index d2cc9900931..4d6220d617d 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -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, diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 5db5d023170..f4cc84e98b6 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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", }), }), diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index cb815fbd291..8aebbcaa703 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -254,6 +254,7 @@ export async function invokeGatewayTool(params: { toolCallId, ctx: { agentId, + config: params.cfg, sessionKey, loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }), }, diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index 5b7c9a93d84..5654965a766 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -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', diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index d49b80d1713..4b7ed98fd7c 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -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`, diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts new file mode 100644 index 00000000000..63de77be1a6 --- /dev/null +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -0,0 +1,1008 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + createPluginRegistryFixture, + registerTestPlugin, +} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadSessionStore, updateSessionStore, type SessionEntry } from "../../config/sessions.js"; +import { withTempConfig } from "../../gateway/test-temp-config.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { cleanupReplacedPluginHostRegistry, runPluginHostCleanup } from "../host-hook-cleanup.js"; +import { clearPluginHostRuntimeState } from "../host-hook-runtime.js"; +import { patchPluginSessionExtension } from "../host-hook-state.js"; +import type { PluginJsonValue } from "../host-hooks.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { setActivePluginRegistry } from "../runtime.js"; +import { createPluginRecord } from "../status.test-helpers.js"; +import { runTrustedToolPolicies } from "../trusted-tool-policy.js"; + +describe("plugin session extension SessionEntry projection", () => { + beforeEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearPluginHostRuntimeState(); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearPluginHostRuntimeState(); + }); + + it("mirrors projected values to SessionEntry[slotKey] and clears them on unset", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "promoted-plugin", name: "Promoted" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + sessionEntrySlotSchema: { type: "object" }, + project: (ctx) => { + if (!ctx.state || typeof ctx.state !== "object" || Array.isArray(ctx.state)) { + return undefined; + } + const state = ctx.state as Record; + return { state: state.state ?? null, title: state.title ?? null }; + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + + const patchResult = await patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "promoted-plugin", + namespace: "workflow", + value: { state: "executing", title: "Deploy approval", internal: 7 }, + }); + expect(patchResult.ok).toBe(true); + const afterPatch = loadSessionStore(storePath, { skipCache: true }); + expect( + (afterPatch["agent:main:main"] as unknown as Record).approvalSnapshot, + ).toEqual({ state: "executing", title: "Deploy approval" }); + + const unsetResult = await patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "promoted-plugin", + namespace: "workflow", + unset: true, + }); + expect(unsetResult.ok).toBe(true); + const afterUnset = loadSessionStore(storePath, { skipCache: true }); + expect( + (afterUnset["agent:main:main"] as unknown as Record).approvalSnapshot, + ).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("clears promoted SessionEntry slots when projectors fail", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "failing-promoted-plugin", name: "Failing" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + sessionEntrySlotSchema: { type: "object" }, + project: (ctx) => { + const state = ctx.state as Record; + if (state.fail === "throw") { + throw new Error("projection failed"); + } + if (state.fail === "promise") { + return Promise.resolve({ state: "async" }) as never; + } + return { state: state.state ?? null }; + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-projector-fail-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "failing-promoted-plugin", + namespace: "workflow", + value: { state: "ready" }, + }), + ).resolves.toMatchObject({ ok: true }); + expect( + ( + loadSessionStore(storePath, { skipCache: true })[ + "agent:main:main" + ] as unknown as Record + ).approvalSnapshot, + ).toEqual({ state: "ready" }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "failing-promoted-plugin", + namespace: "workflow", + value: { state: "bad", fail: "throw" }, + }), + ).resolves.toMatchObject({ ok: true }); + const afterThrow = loadSessionStore(storePath, { skipCache: true })[ + "agent:main:main" + ] as unknown as Record; + expect(afterThrow.approvalSnapshot).toBeUndefined(); + expect(afterThrow.pluginExtensions).toMatchObject({ + "failing-promoted-plugin": { + workflow: { state: "bad", fail: "throw" }, + }, + }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "failing-promoted-plugin", + namespace: "workflow", + value: { state: "ready-again" }, + }), + ).resolves.toMatchObject({ ok: true }); + expect( + ( + loadSessionStore(storePath, { skipCache: true })[ + "agent:main:main" + ] as unknown as Record + ).approvalSnapshot, + ).toEqual({ state: "ready-again" }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "failing-promoted-plugin", + namespace: "workflow", + value: { state: "async-bad", fail: "promise" }, + }), + ).resolves.toMatchObject({ ok: true }); + const afterPromise = loadSessionStore(storePath, { skipCache: true })[ + "agent:main:main" + ] as unknown as Record; + expect(afterPromise.approvalSnapshot).toBeUndefined(); + expect(afterPromise.pluginExtensions).toMatchObject({ + "failing-promoted-plugin": { + workflow: { state: "async-bad", fail: "promise" }, + }, + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("rejects sessionEntrySlotKey values that collide with SessionEntry fields", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "slot-collision", name: "Slot Collision" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "bad slot", + sessionEntrySlotKey: "updatedAt", + }); + api.registerSessionExtension({ + namespace: "recovery", + description: "bad fresh-main slot", + sessionEntrySlotKey: "subagentRecovery", + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: updatedAt", + }), + expect.objectContaining({ + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: subagentRecovery", + }), + ]), + ); + }); + + it("rejects sessionEntrySlotKey values inherited from Object.prototype", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "object-slot-collision", name: "Object Slot Collision" }), + register(api) { + api.registerSessionExtension({ + namespace: "to-string", + description: "bad object slot", + sessionEntrySlotKey: "toString", + }); + api.registerSessionExtension({ + namespace: "has-own", + description: "bad object slot", + sessionEntrySlotKey: "hasOwnProperty", + }); + api.registerSessionExtension({ + namespace: "value-of", + description: "bad object slot", + sessionEntrySlotKey: "valueOf", + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "object-slot-collision", + message: "sessionEntrySlotKey is reserved by Object: toString", + }), + expect.objectContaining({ + pluginId: "object-slot-collision", + message: "sessionEntrySlotKey is reserved by Object: hasOwnProperty", + }), + expect.objectContaining({ + pluginId: "object-slot-collision", + message: "sessionEntrySlotKey is reserved by Object: valueOf", + }), + ]), + ); + }); + + it("rejects duplicate promoted SessionEntry slot keys across registrations", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "slot-owner", name: "Slot Owner" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "first promoted slot", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "recovery", + description: "same plugin duplicate slot", + sessionEntrySlotKey: " approvalSnapshot ", + }); + }, + }); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "slot-colliding-plugin", name: "Slot Colliding" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "cross-plugin duplicate slot", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(1); + expect(registry.registry.sessionExtensions?.[0]?.extension.sessionEntrySlotKey).toBe( + "approvalSnapshot", + ); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "slot-owner", + message: "sessionEntrySlotKey already registered: approvalSnapshot", + }), + expect.objectContaining({ + pluginId: "slot-colliding-plugin", + message: "sessionEntrySlotKey already registered: approvalSnapshot", + }), + ]), + ); + }); + + it("clears promoted SessionEntry slots with plugin-owned session state", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "cleanup-promoted-plugin", name: "Cleanup" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-cleanup-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "cleanup-promoted-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig as never, + registry: registry.registry, + pluginId: "cleanup-promoted-plugin", + reason: "delete", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.pluginExtensions).toBeUndefined(); + expect(entry.approvalSnapshot).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("uses the active registry to clear promoted slots when cleanup omits registry", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "active-cleanup-promoted-plugin", name: "Cleanup" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-active-cleanup-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "active-cleanup-promoted-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig as never, + pluginId: "active-cleanup-promoted-plugin", + reason: "delete", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.pluginExtensions).toBeUndefined(); + expect(entry.approvalSnapshot).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("clears stale promoted SessionEntry slots on plugin restart without deleting extension state", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-promoted-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-promoted-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-cleanup-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-promoted-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig as never, + previousRegistry: previousFixture.registry.registry, + nextRegistry: nextFixture.registry.registry, + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toBeUndefined(); + expect(entry.pluginExtensions).toEqual({ + "restart-promoted-plugin": { + workflow: { state: "waiting" }, + }, + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("clears only stale promoted SessionEntry slots on mixed plugin restart", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-mixed-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "legacy", + description: "legacy promoted workflow", + sessionEntrySlotKey: "legacyApprovalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-mixed-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "legacy", + description: "legacy workflow", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-mixed-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-mixed-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-mixed-plugin", + namespace: "legacy", + value: { state: "legacy" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig as never, + previousRegistry: previousFixture.registry.registry, + nextRegistry: nextFixture.registry.registry, + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toEqual({ state: "waiting" }); + expect(entry.legacyApprovalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toEqual({ + "restart-mixed-plugin": { + workflow: "approvalSnapshot", + }, + }); + expect(entry.pluginExtensions).toEqual({ + "restart-mixed-plugin": { + workflow: { state: "waiting" }, + legacy: { state: "legacy" }, + }, + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("preserves promoted SessionEntry slots on plugin restart when the slot is still declared", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-preserved-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-preserved-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-preserve-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-preserved-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig as never, + previousRegistry: previousFixture.registry.registry, + nextRegistry: nextFixture.registry.registry, + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toEqual({ state: "waiting" }); + expect(entry.pluginExtensionSlotKeys).toEqual({ + "restart-preserved-plugin": { + workflow: "approvalSnapshot", + }, + }); + expect(entry.pluginExtensions).toEqual({ + "restart-preserved-plugin": { + workflow: { state: "waiting" }, + }, + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("clears persisted promoted slots when registry metadata is unavailable", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-metadata-cleanup-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + pluginExtensions: { + "removed-promoted-plugin": { + workflow: { state: "stale" }, + }, + }, + pluginExtensionSlotKeys: { + "removed-promoted-plugin": { + workflow: "approvalSnapshot", + }, + }, + approvalSnapshot: { state: "stale" }, + } as unknown as SessionEntry; + }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig as never, + pluginId: "removed-promoted-plugin", + reason: "delete", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toBeUndefined(); + expect(entry.pluginExtensions).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("exposes scoped session extension reads to trusted tool policies", async () => { + const seen: unknown[] = []; + const seenConfig: unknown[] = []; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "policy-plugin", + name: "Policy Plugin", + origin: "bundled", + }), + register(api) { + api.registerSessionExtension({ + namespace: "policy", + description: "policy state", + }); + api.registerSessionExtension({ + namespace: "second", + description: "second policy state", + }); + api.registerTrustedToolPolicy({ + id: "inspect-session-state", + description: "inspect session extension", + evaluate(_event, ctx) { + seen.push(ctx.getSessionExtension?.("policy")); + seen.push(ctx.getSessionExtension?.("second")); + seen.push(ctx.getSessionExtension?.("missing")); + seenConfig.push((ctx as { config?: unknown }).config); + return undefined; + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-policy-read-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "policy-plugin", + namespace: "policy", + value: { gate: "open" }, + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "policy-plugin", + namespace: "second", + value: { gate: "second" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + runTrustedToolPolicies( + { toolName: "apply_patch", params: {} }, + { + toolName: "apply_patch", + sessionKey: "agent:main:main", + }, + { config: tempConfig as never }, + ), + ).resolves.toBeUndefined(); + + await expect( + runTrustedToolPolicies( + { toolName: "apply_patch", params: {} }, + { + toolName: "apply_patch", + sessionKey: "agent:main:main", + }, + ), + ).resolves.toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + + expect(seen).toEqual([ + { gate: "open" }, + { gate: "second" }, + undefined, + { gate: "open" }, + { gate: "second" }, + undefined, + ]); + expect(seenConfig).toEqual([undefined, undefined]); + }); + + it("does not touch top-level SessionEntry slots when sessionEntrySlotKey is omitted", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "non-promoted-plugin", name: "Non" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "non-promoted workflow", + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-noop-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + } as unknown as SessionEntry; + }); + const result = await patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "non-promoted-plugin", + namespace: "workflow", + value: { state: "executing" }, + }); + expect(result.ok).toBe(true); + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 04e41afe370..996e41b88e9 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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?: ( + namespace: string, + ) => T | undefined; }; export type PluginHookBeforeToolCallEvent = { diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 1ee90dac7f0..6f5d9ce4774 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -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 { + const slotKeys = new Set(); + 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, +): Set { + const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId); + for (const slotKey of sessionEntrySlotKeys ?? []) { + slotKeys.add(slotKey); + } + return slotKeys; +} + +function clearPromotedSessionEntrySlots( + entry: SessionEntry, + pluginId?: string, + sessionEntrySlotKeys?: ReadonlySet, + options: { includeStoredSlotKeys?: boolean; pruneSlotOwnership?: boolean } = {}, +): void { + const slotKeys = + options.includeStoredSlotKeys === false && sessionEntrySlotKeys + ? new Set(sessionEntrySlotKeys) + : collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); + const entryRecord = entry as Record; + for (const slotKey of slotKeys) { + delete entryRecord[slotKey]; + } + if (!options.pruneSlotOwnership || !entry.pluginExtensionSlotKeys) { + return; + } + const pruneRecord = (record: Record): 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, +): 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, +): 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; + for (const slotKey of slotKeys) { + if (Object.prototype.hasOwnProperty.call(entryRecord, slotKey)) { + return true; + } + } + return false; +} + +function hasPluginOwnedSessionState( + entry: SessionEntry, + pluginId?: string, + sessionEntrySlotKeys?: ReadonlySet, +): 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; }): Promise { 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; +}): Promise { + 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 { + const slotKeys = new Set(); + 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; shouldCleanup?: () => boolean; + restartPromotedSessionEntrySlotKeys?: ReadonlySet; }): Promise { 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 { + 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); diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts index 529f0fd68e4..c3ff95e8ba8 100644 --- a/src/plugins/host-hook-state.ts +++ b/src/plugins/host-hook-state.ts @@ -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(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 | 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 + | undefined; + return value ? (copyJsonValue(value) as Record) : 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; 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; diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index fcb03239ffa..78aa3891fd6 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -39,6 +39,23 @@ export type PluginSessionExtensionRegistration = { description: string; project?: (ctx: PluginSessionExtensionProjectionContext) => PluginJsonValue | undefined; cleanup?: (ctx: { reason: PluginHostCleanupReason; sessionKey?: string }) => void | Promise; + /** + * When set, after every successful `patchSessionExtension` the projected + * value is mirrored to `SessionEntry[]` 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 = { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 6ce09bb6f9b..497e1d37661 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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, diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts new file mode 100644 index 00000000000..8415d82249c --- /dev/null +++ b/src/plugins/session-entry-slot-keys.ts @@ -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; + +type ReservedSessionEntrySlotKey = Extract< + (typeof SESSION_ENTRY_RESERVED_SLOT_KEY_LIST)[number], + keyof SessionEntry +>; +type MissingSessionEntryReservedSlotKeys = Exclude; +type AssertNever = T; +type _AssertAllSessionEntryKeysAreReserved = AssertNever; + +const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set(SESSION_ENTRY_RESERVED_SLOT_KEY_LIST); +const OBJECT_PROTOTYPE_RESERVED_SLOT_KEYS = new Set([ + "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 }; +} diff --git a/src/plugins/trusted-tool-policy.ts b/src/plugins/trusted-tool-policy.ts index 4d153d0c7f3..fb4caab828f 100644 --- a/src/plugins/trusted-tool-policy.ts +++ b/src/plugins/trusted-tool-policy.ts @@ -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 { const policies = getActivePluginRegistry()?.trustedToolPolicies ?? []; let adjustedParams = event.params; let hasAdjustedParams = false; let approval: PluginHookBeforeToolCallResult["requireApproval"]; + const sessionExtensionStateCache = new Map | 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: (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; } diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index 93f88f80af1..0fd7348b1dc 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -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",