mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1622,6 +1622,7 @@ export async function runEmbeddedAttempt(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
config: params.config,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
loopDetection: clientToolLoopDetection,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -254,6 +254,7 @@ export async function invokeGatewayTool(params: {
|
||||
toolCallId,
|
||||
ctx: {
|
||||
agentId,
|
||||
config: params.cfg,
|
||||
sessionKey,
|
||||
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
|
||||
1008
src/plugins/contracts/session-entry-projection.contract.test.ts
Normal file
1008
src/plugins/contracts/session-entry-projection.contract.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
152
src/plugins/session-entry-slot-keys.ts
Normal file
152
src/plugins/session-entry-slot-keys.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user