Merge f1b9e0fde92d5b303ff2b827bf5d2e16ce02f621 into 43513cd1df63af0704dfb351ee7864607f955dcc

This commit is contained in:
MrGPUs 2026-03-21 01:40:04 -04:00 committed by GitHub
commit 4cc5598891
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 941 additions and 0 deletions

View File

@ -1,6 +1,7 @@
import path from "node:path";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { registerContextEngineForOwner } from "../context-engine/registry.js";
import type {
GatewayRequestHandler,
@ -48,6 +49,7 @@ import type {
PluginOrigin,
PluginKind,
PluginRegistrationMode,
PluginResetSessionResult,
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
@ -55,6 +57,8 @@ import type {
WebSearchProviderPlugin,
} from "./types.js";
type GatewaySessionResetModule = typeof import("../gateway/session-reset-service.js");
export type PluginToolRegistration = {
pluginId: string;
pluginName?: string;
@ -221,12 +225,148 @@ export type PluginRegistryParams = {
// When true, skip writing to the global plugin command registry during register().
// Used by non-activating snapshot loads to avoid leaking commands into the running gateway.
suppressGlobalCommands?: boolean;
loadSessionResetModule?: () => Promise<GatewaySessionResetModule>;
};
type PluginTypedHookPolicy = {
allowPromptInjection?: boolean;
};
const FALLBACK_AGENT_ID = "main";
const DEFAULT_MAIN_SESSION_KEY = "main";
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_AGENT_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
const isGlobalSessionKey = (value: string) => value === "global" || value === "unknown";
const normalizePluginMainKey = (value?: string) => {
const trimmed = (value ?? "").trim();
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_SESSION_KEY;
};
const normalizePluginAgentId = (value?: string) => {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return FALLBACK_AGENT_ID;
}
if (VALID_AGENT_ID_RE.test(trimmed)) {
return trimmed.toLowerCase();
}
return (
trimmed
.toLowerCase()
.replace(INVALID_AGENT_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || FALLBACK_AGENT_ID
);
};
const parsePluginAgentSessionKey = (
sessionKey: string,
): { agentId: string; rest: string } | null => {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return null;
}
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3 || parts[0] !== "agent") {
return null;
}
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) {
return null;
}
return { agentId, rest };
};
const buildPluginAgentMainSessionKey = (params: { agentId: string; mainKey?: string }): string => {
return `agent:${normalizePluginAgentId(params.agentId)}:${normalizePluginMainKey(params.mainKey)}`;
};
const canonicalizePluginMainSessionAlias = (params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
}): string => {
const raw = params.sessionKey.trim();
if (!raw) {
return "";
}
const normalized = raw.toLowerCase();
const normalizedAgent = normalizePluginAgentId(params.agentId);
const normalizedMainKey = normalizePluginMainKey(params.cfg.session?.mainKey);
const agentMainSessionKey = buildPluginAgentMainSessionKey({
agentId: normalizedAgent,
mainKey: normalizedMainKey,
});
const agentMainAliasKey = buildPluginAgentMainSessionKey({
agentId: normalizedAgent,
mainKey: DEFAULT_MAIN_SESSION_KEY,
});
const isAlias =
normalized === "main" ||
normalized === normalizedMainKey ||
normalized === agentMainSessionKey ||
normalized === agentMainAliasKey;
if (params.cfg.session?.scope === "global" && isAlias) {
return "global";
}
return isAlias ? agentMainSessionKey : normalized;
};
const resolveDefaultPluginAgentId = (cfg: OpenClawConfig): string => {
const agents = Array.isArray(cfg.agents?.list)
? cfg.agents.list.filter((agent) => Boolean(agent && typeof agent === "object"))
: [];
if (agents.length === 0) {
return FALLBACK_AGENT_ID;
}
const preferredEntry = agents.find((agent) => agent?.default) ?? agents[0];
const preferredId = typeof preferredEntry?.id === "string" ? preferredEntry.id : undefined;
return normalizePluginAgentId(preferredId);
};
const resolvePluginMainSessionKey = (cfg: OpenClawConfig): string => {
if (cfg.session?.scope === "global") {
return "global";
}
return buildPluginAgentMainSessionKey({
agentId: resolveDefaultPluginAgentId(cfg),
mainKey: cfg.session?.mainKey,
});
};
const resolveCanonicalPluginSessionKey = (cfg: OpenClawConfig, rawKey: string): string => {
const trimmedKey = rawKey.trim();
if (!trimmedKey) {
return "";
}
const lowered = trimmedKey.toLowerCase();
if (isGlobalSessionKey(lowered)) {
return lowered;
}
const parsed = parsePluginAgentSessionKey(trimmedKey);
if (parsed) {
return canonicalizePluginMainSessionAlias({
cfg,
agentId: parsed.agentId,
sessionKey: trimmedKey,
});
}
const normalizedMainKey = normalizePluginMainKey(cfg.session?.mainKey);
if (lowered === "main" || lowered === normalizedMainKey) {
return resolvePluginMainSessionKey(cfg);
}
if (lowered.startsWith("agent:")) {
return lowered;
}
return `agent:${resolveDefaultPluginAgentId(cfg)}:${lowered}`;
};
const constrainLegacyPromptInjectionHook = (
handler: PluginHookHandlerMap["before_agent_start"],
): PluginHookHandlerMap["before_agent_start"] => {
@ -246,11 +386,135 @@ export { createEmptyPluginRegistry } from "./registry-empty.js";
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
const isGatewayRuntimeAvailable = () => {
const subagentRuntime = registryParams.runtime.subagent;
return (
Boolean(subagentRuntime) && !Object.is(subagentRuntime.run, subagentRuntime.deleteSession)
);
};
const runtimeLoadConfig = () => {
const loadConfig = registryParams.runtime.config?.loadConfig;
if (typeof loadConfig !== "function") {
throw new Error("Plugin runtime config loader is unavailable.");
}
return loadConfig();
};
const gatewayResetUnavailableError =
"resetSession is only available while the gateway is running.";
let sessionResetModuleCache: GatewaySessionResetModule | null = null;
const loadSessionResetModule = (() => {
if (registryParams.loadSessionResetModule) {
return registryParams.loadSessionResetModule;
}
return async () => {
if (!sessionResetModuleCache) {
sessionResetModuleCache = await import("../gateway/session-reset-service.js");
}
return sessionResetModuleCache;
};
})();
const resetSessionsInFlight = new Set<string>();
const pushDiagnostic = (diag: PluginDiagnostic) => {
registry.diagnostics.push(diag);
};
const normalizeResetSessionError = (error: unknown): string => {
if (error instanceof Error) {
return error.message || "Session reset failed.";
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object") {
const maybeMessage = Reflect.get(error, "message");
if (typeof maybeMessage === "string" && maybeMessage.trim()) {
return maybeMessage;
}
const nestedError = Reflect.get(error, "error");
if (nestedError && typeof nestedError === "object") {
const nestedMessage = Reflect.get(nestedError, "message");
if (typeof nestedMessage === "string" && nestedMessage.trim()) {
return nestedMessage;
}
}
}
return "Session reset failed.";
};
const createResetSessionFailure = (
key: string,
error: unknown,
): Extract<PluginResetSessionResult, { ok: false }> => ({
ok: false,
key,
error: normalizeResetSessionError(error),
});
const createPluginResetSession = (params: {
pluginId: string;
loadConfig: () => OpenClawConfig;
loadSessionResetModule: () => Promise<GatewaySessionResetModule>;
isGatewayRuntimeAvailable: () => boolean;
}): NonNullable<OpenClawPluginApi["resetSession"]> => {
return async (key, reason = "new") => {
let responseKey = typeof key === "string" ? key.trim() : "";
try {
if (typeof key !== "string") {
throw new TypeError("resetSession key must be a string");
}
const trimmedKey = key.trim();
responseKey = trimmedKey;
if (!trimmedKey) {
throw new Error("resetSession key must be a non-empty string");
}
if (!params.isGatewayRuntimeAvailable()) {
return createResetSessionFailure(trimmedKey, gatewayResetUnavailableError);
}
const normalizedReason = reason === "reset" ? "reset" : "new";
const liveConfig = params.loadConfig();
const canonicalKey = resolveCanonicalPluginSessionKey(liveConfig, trimmedKey).trim();
if (!canonicalKey) {
throw new Error("Session reset failed to resolve a canonical session key");
}
responseKey = canonicalKey;
if (resetSessionsInFlight.has(canonicalKey)) {
return createResetSessionFailure(
canonicalKey,
`Session reset already in progress for ${canonicalKey}.`,
);
}
resetSessionsInFlight.add(canonicalKey);
try {
const { performGatewaySessionReset } = await params.loadSessionResetModule();
const result = await performGatewaySessionReset({
key: trimmedKey,
reason: normalizedReason,
commandSource: `plugin:${params.pluginId}`,
});
if (result.ok) {
return {
ok: true,
key: result.key,
sessionId: result.entry.sessionId,
};
}
return createResetSessionFailure(canonicalKey, result.error);
} finally {
resetSessionsInFlight.delete(canonicalKey);
}
} catch (error) {
return createResetSessionFailure(responseKey, error);
}
};
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
@ -979,6 +1243,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
}
},
resetSession:
registrationMode === "full"
? createPluginResetSession({
pluginId: record.id,
loadConfig: runtimeLoadConfig,
loadSessionResetModule,
isGatewayRuntimeAvailable,
})
: undefined,
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) =>
registrationMode === "full"

View File

@ -0,0 +1,664 @@
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "./registry.js";
import type { PluginRuntime } from "./runtime/types.js";
import type {
OpenClawPluginApi,
PluginRegistrationMode,
PluginResetSessionResult,
} from "./types.js";
const resolveModuleId = (specifier: string) => fileURLToPath(new URL(specifier, import.meta.url));
const AUTH_PROFILES_OAUTH_MODULE_IDS = [
"../agents/auth-profiles/oauth.js",
"../agents/auth-profiles/oauth.ts",
resolveModuleId("../agents/auth-profiles/oauth.js"),
resolveModuleId("../agents/auth-profiles/oauth.ts"),
];
const MODEL_AUTH_MODULE_IDS = [
"../agents/model-auth.js",
"../agents/model-auth.ts",
resolveModuleId("../agents/model-auth.js"),
resolveModuleId("../agents/model-auth.ts"),
];
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthProviders: () => [],
getOAuthApiKey: vi.fn(async () => ""),
loginOpenAICodex: vi.fn(),
}));
const mockPluginSideEffects = () => {
vi.doMock("../agents/sandbox/constants.js", () => {
return {
DEFAULT_SANDBOX_WORKSPACE_ROOT: "/tmp/sandboxes",
DEFAULT_SANDBOX_IMAGE: "sandbox:test",
DEFAULT_SANDBOX_CONTAINER_PREFIX: "sandbox-",
DEFAULT_SANDBOX_WORKDIR: "/workspace",
DEFAULT_SANDBOX_IDLE_HOURS: 1,
DEFAULT_SANDBOX_MAX_AGE_DAYS: 1,
DEFAULT_TOOL_ALLOW: [] as const,
DEFAULT_TOOL_DENY: [] as const,
DEFAULT_SANDBOX_BROWSER_IMAGE: "sandbox-browser:test",
DEFAULT_SANDBOX_COMMON_IMAGE: "sandbox-common:test",
SANDBOX_BROWSER_SECURITY_HASH_EPOCH: "test",
DEFAULT_SANDBOX_BROWSER_PREFIX: "sandbox-browser-",
DEFAULT_SANDBOX_BROWSER_NETWORK: "sandbox-net",
DEFAULT_SANDBOX_BROWSER_CDP_PORT: 0,
DEFAULT_SANDBOX_BROWSER_VNC_PORT: 0,
DEFAULT_SANDBOX_BROWSER_NOVNC_PORT: 0,
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS: 0,
SANDBOX_AGENT_WORKSPACE_MOUNT: "/agent",
SANDBOX_STATE_DIR: "/tmp/sandbox",
SANDBOX_REGISTRY_PATH: "/tmp/sandbox/containers.json",
SANDBOX_BROWSER_REGISTRY_PATH: "/tmp/sandbox/browsers.json",
};
});
const mockAuthProfilesOauth = {
resolveApiKeyForProfile: vi.fn(async () => ({
apiKey: "",
provider: "mock",
})),
getOAuthProviders: vi.fn(() => []),
};
for (const id of AUTH_PROFILES_OAUTH_MODULE_IDS) {
vi.doMock(id, () => mockAuthProfilesOauth);
}
for (const id of MODEL_AUTH_MODULE_IDS) {
vi.doMock(id, () => ({
getApiKeyForModel: vi.fn(async () => null),
resolveApiKeyForProvider: vi.fn(async () => null),
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
resolveAuthProfileOrder: vi.fn(() => []),
}));
}
vi.doMock("openclaw/plugin-sdk/text-runtime", async () => {
return await import("../plugin-sdk/text-runtime.js");
});
vi.doMock("../channels/registry.js", () => ({
CHAT_CHANNEL_ORDER: [],
CHANNEL_IDS: [],
listChatChannels: () => [],
listChatChannelAliases: () => [],
getChatChannelMeta: () => ({ id: "demo-channel" }),
normalizeChatChannelId: () => null,
normalizeChannelId: () => null,
normalizeAnyChannelId: () => null,
}));
vi.doMock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class {
connect = vi.fn(async () => {});
listTools = vi.fn(async () => ({ tools: [] }));
close = vi.fn(async () => {});
},
}));
vi.doMock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: class {
pid = null;
},
}));
vi.doMock("../auto-reply/reply/get-reply-directives.js", () => ({
resolveReplyDirectives: vi.fn(async () => ({ kind: "reply", reply: undefined })),
}));
vi.doMock("./web-search-providers.js", () => ({
resolvePluginWebSearchProviders: () => [],
resolveRuntimeWebSearchProviders: () => [],
}));
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
augmentModelCatalogWithProviderPlugins: vi.fn(),
buildProviderAuthDoctorHintWithPlugin: vi.fn(() => ""),
buildProviderMissingAuthMessageWithPlugin: vi.fn(() => ""),
formatProviderAuthProfileApiKeyWithPlugin: vi.fn(
({ context }: { context?: { access?: string } }) => context?.access ?? "",
),
refreshProviderOAuthCredentialWithPlugin: vi.fn(async () => null),
}));
};
type SessionResetModule = typeof import("../gateway/session-reset-service.js");
type SessionResetDeps = {
loadConfig: ReturnType<typeof vi.fn>;
performGatewaySessionReset: ReturnType<typeof vi.fn>;
};
type RegistryImportOptions = {
sessionResetImportError?: Error;
registrationMode?: PluginRegistrationMode;
gatewaySupportsReset?: boolean;
runtimeAvailable?: boolean;
};
function createRecord(): PluginRecord {
return {
id: "demo-plugin",
name: "Demo Plugin",
source: "/tmp/demo-plugin.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
};
}
async function createApiHarness(options?: RegistryImportOptions) {
vi.resetModules();
mockPluginSideEffects();
const deps: SessionResetDeps = {
loadConfig: vi.fn(() => ({}) as OpenClawConfig),
performGatewaySessionReset: vi.fn(async (payload) => ({
ok: true,
key: payload.key,
entry: { sessionId: "session-default" },
})),
};
const createSessionResetModuleMock = (): SessionResetModule => ({
archiveSessionTranscriptsForSession: vi.fn(() => []),
cleanupSessionBeforeMutation: vi.fn(async () => undefined),
emitSessionUnboundLifecycleEvent: vi.fn(async () => {}),
performGatewaySessionReset:
deps.performGatewaySessionReset as SessionResetModule["performGatewaySessionReset"],
});
const loadSessionResetModule: () => Promise<SessionResetModule> =
typeof options?.sessionResetImportError === "undefined"
? async () => createSessionResetModuleMock()
: async () => {
throw options.sessionResetImportError;
};
const { createPluginRegistry } = await import("./registry.js");
const { createApi } = createPluginRegistry({
logger: {
info: () => {},
warn: () => {},
error: () => {},
},
coreGatewayHandlers:
options?.gatewaySupportsReset === false ? {} : { "sessions.reset": async () => {} },
runtime: createTestRuntime({
available: options?.runtimeAvailable !== false,
loadConfig: deps.loadConfig,
}),
loadSessionResetModule,
});
const api = createApi(createRecord(), {
config: {} as OpenClawConfig,
registrationMode: options?.registrationMode,
});
return { api, deps };
}
function createTestRuntime(params: {
available?: boolean;
loadConfig: SessionResetDeps["loadConfig"];
}): PluginRuntime {
const run = vi.fn(async () => ({ runId: "run" }));
const deleteSession =
params.available === false
? run
: vi.fn(async () => {
return;
});
return {
version: "test",
config: {
loadConfig: params.loadConfig as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn(),
},
subagent: {
run,
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
deleteSession,
},
} as unknown as PluginRuntime;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function createApiWithDefaultMocks() {
const harness = await createApiHarness();
harness.deps.loadConfig.mockReturnValue({} as OpenClawConfig);
return harness;
}
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.doUnmock("../agents/sandbox/constants.js");
for (const id of AUTH_PROFILES_OAUTH_MODULE_IDS) {
vi.doUnmock(id);
}
for (const id of MODEL_AUTH_MODULE_IDS) {
vi.doUnmock(id);
}
vi.doUnmock("../channels/registry.js");
vi.doUnmock("@modelcontextprotocol/sdk/client/index.js");
vi.doUnmock("@modelcontextprotocol/sdk/client/stdio.js");
vi.doUnmock("../auto-reply/reply/get-reply-directives.js");
vi.doUnmock("../gateway/session-reset-service.js");
vi.doUnmock("openclaw/plugin-sdk/text-runtime");
vi.doUnmock("./web-search-providers.js");
vi.doUnmock("../plugins/provider-runtime.runtime.js");
});
describe("plugin resetSession", () => {
describe("type and API exposure", () => {
it("keeps the exported API type feature-detectable", () => {
expectTypeOf<OpenClawPluginApi["resetSession"]>().toEqualTypeOf<
((key: string, reason?: "new" | "reset") => Promise<PluginResetSessionResult>) | undefined
>();
});
it("exposes resetSession on the real API object returned by createApi", async () => {
const { api } = await createApiHarness();
expect(api).toHaveProperty("resetSession");
expect(api.resetSession).toBeTypeOf("function");
});
it("omits resetSession in setup-only registration mode", async () => {
const { api } = await createApiHarness({ registrationMode: "setup-only" });
expect(api.resetSession).toBeUndefined();
});
it("omits resetSession in setup-runtime registration mode", async () => {
const { api } = await createApiHarness({ registrationMode: "setup-runtime" });
expect(api.resetSession).toBeUndefined();
});
});
describe("gateway availability guard", () => {
it("fails fast when the runtime subagent is unavailable", async () => {
const { api, deps } = await createApiHarness({ runtimeAvailable: false });
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "resetSession is only available while the gateway is running.",
});
expect(deps.loadConfig).not.toHaveBeenCalled();
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
});
it("does not attempt to import the reset service when the gateway is unavailable", async () => {
const { api } = await createApiHarness({
runtimeAvailable: false,
sessionResetImportError: new Error("reset module should not load"),
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "resetSession is only available while the gateway is running.",
});
});
it("still resolves the reset service when the registry was constructed without sessions.reset", async () => {
const { api, deps } = await createApiHarness({ gatewaySupportsReset: false });
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: true,
key: "agent:main:demo",
sessionId: "session-default",
});
expect(deps.performGatewaySessionReset).toHaveBeenCalledTimes(1);
});
});
describe("validation and success mapping", () => {
it("normalizes non-string, empty, and whitespace-only keys to failure results", async () => {
const { api, deps } = await createApiHarness();
await expect(api.resetSession?.(123 as never)).resolves.toEqual({
ok: false,
key: "",
error: "resetSession key must be a string",
});
await expect(api.resetSession?.("")).resolves.toEqual({
ok: false,
key: "",
error: "resetSession key must be a non-empty string",
});
await expect(api.resetSession?.(" ")).resolves.toEqual({
ok: false,
key: "",
error: "resetSession key must be a non-empty string",
});
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
});
it("trims the key, defaults reason to new, and maps success without leaking gateway internals", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockResolvedValue({
ok: true,
key: "agent:main:canonical",
entry: { sessionId: "session-123", hidden: true },
});
const result = await api.resetSession?.(" agent:main:demo ");
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
key: "agent:main:demo",
reason: "new",
commandSource: "plugin:demo-plugin",
});
expect(deps.loadConfig).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: true,
key: "agent:main:canonical",
sessionId: "session-123",
});
expect(result).not.toHaveProperty("entry");
});
it("preserves an explicit reset reason", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockResolvedValue({
ok: true,
key: "agent:main:demo",
entry: { sessionId: "session-456" },
});
await api.resetSession?.("agent:main:demo", "reset");
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
key: "agent:main:demo",
reason: "reset",
commandSource: "plugin:demo-plugin",
});
});
it("uses live config for canonicalization instead of the captured API config", async () => {
const { api, deps } = await createApiHarness();
const liveConfig = {
agents: { list: [{ id: "ops", default: true }] },
session: { mainKey: "work" },
} as OpenClawConfig;
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
deps.loadConfig.mockReturnValue(liveConfig);
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
const first = api.resetSession?.("agent:ops:MAIN");
const second = await api.resetSession?.("agent:ops:work");
expect(second).toEqual({
ok: false,
key: "agent:ops:work",
error: "Session reset already in progress for agent:ops:work.",
});
pending.resolve({
ok: true,
key: "agent:ops:work",
entry: { sessionId: "session-live-config" },
});
await expect(first).resolves.toEqual({
ok: true,
key: "agent:ops:work",
sessionId: "session-live-config",
});
expect(deps.loadConfig).toHaveBeenCalledTimes(2);
});
it('falls back to "new" for invalid runtime reason values', async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockResolvedValue({
ok: true,
key: "agent:main:demo",
entry: { sessionId: "session-fallback" },
});
await expect(api.resetSession?.("agent:main:demo", "invalid" as never)).resolves.toEqual({
ok: true,
key: "agent:main:demo",
sessionId: "session-fallback",
});
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
key: "agent:main:demo",
reason: "new",
commandSource: "plugin:demo-plugin",
});
});
});
describe("failure normalization", () => {
it("normalizes gateway failure objects to string errors", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockResolvedValue({
ok: false,
error: { code: "UNAVAILABLE", message: "try again later" },
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "try again later",
});
});
it("normalizes helper throws from Error and string values", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockRejectedValueOnce(new Error("boom error"));
deps.performGatewaySessionReset.mockRejectedValueOnce("boom string");
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "boom error",
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "boom string",
});
});
it("normalizes canonicalization failure before invoking the reset helper", async () => {
const { api, deps } = await createApiHarness();
deps.loadConfig.mockImplementation(() => {
throw new Error("bad session key");
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "bad session key",
});
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
});
it("normalizes session-reset import failures", async () => {
const { api } = await createApiHarness({
sessionResetImportError: new Error("import setup failed"),
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "import setup failed",
});
});
it("resolves structured failure instead of rejecting for operational failures", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset.mockResolvedValue({
ok: false,
error: { message: "gateway rejected" },
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "gateway rejected",
});
});
});
describe("in-flight guard behavior", () => {
it("blocks same canonical key while a reset is already in flight", async () => {
const { api, deps } = await createApiWithDefaultMocks();
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
const first = api.resetSession?.("agent:main:demo");
const second = await api.resetSession?.("agent:main:demo");
expect(second).toEqual({
ok: false,
key: "agent:main:demo",
error: "Session reset already in progress for agent:main:demo.",
});
pending.resolve({
ok: true,
key: "agent:main:demo",
entry: { sessionId: "session-1" },
});
await expect(first).resolves.toEqual({
ok: true,
key: "agent:main:demo",
sessionId: "session-1",
});
});
it("allows different canonical keys concurrently", async () => {
const { api, deps } = await createApiHarness();
const first = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
const second = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
deps.performGatewaySessionReset
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const firstCall = api.resetSession?.("agent:main:a");
const secondCall = api.resetSession?.("agent:main:b");
second.resolve({
ok: true,
key: "agent:main:b",
entry: { sessionId: "session-b" },
});
first.resolve({
ok: true,
key: "agent:main:a",
entry: { sessionId: "session-a" },
});
await expect(firstCall).resolves.toEqual({
ok: true,
key: "agent:main:a",
sessionId: "session-a",
});
await expect(secondCall).resolves.toEqual({
ok: true,
key: "agent:main:b",
sessionId: "session-b",
});
});
it("blocks alias keys that resolve to the same canonical key", async () => {
const { api, deps } = await createApiHarness();
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
deps.loadConfig.mockReturnValue({
agents: { list: [{ id: "ops", default: true }] },
session: { mainKey: "work" },
} as OpenClawConfig);
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
const first = api.resetSession?.("agent:ops:MAIN");
const second = await api.resetSession?.("agent:ops:work");
expect(second).toEqual({
ok: false,
key: "agent:ops:work",
error: "Session reset already in progress for agent:ops:work.",
});
pending.resolve({
ok: true,
key: "agent:ops:work",
entry: { sessionId: "session-work" },
});
await first;
});
it("releases the in-flight guard after success and after failure", async () => {
const { api, deps } = await createApiWithDefaultMocks();
deps.performGatewaySessionReset
.mockResolvedValueOnce({
ok: true,
key: "agent:main:demo",
entry: { sessionId: "session-ok" },
})
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce({
ok: true,
key: "agent:main:demo",
entry: { sessionId: "session-after-failure" },
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: true,
key: "agent:main:demo",
sessionId: "session-ok",
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: false,
key: "agent:main:demo",
error: "temporary failure",
});
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
ok: true,
key: "agent:main:demo",
sessionId: "session-after-failure",
});
});
});
});

View File

@ -1282,6 +1282,9 @@ export type OpenClawPluginModule =
| ((api: OpenClawPluginApi) => void | Promise<void>);
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime";
export type PluginResetSessionResult =
| { ok: true; key: string; sessionId: string }
| { ok: false; key: string; error: string };
export type OpenClawPluginApi = {
id: string;
@ -1341,6 +1344,7 @@ export type OpenClawPluginApi = {
id: string,
factory: import("../context-engine/registry.js").ContextEngineFactory,
) => void;
resetSession?: (key: string, reason?: "new" | "reset") => Promise<PluginResetSessionResult>;
resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(