mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
fix: guard stale run context APIs
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
import { createEmptyPluginRegistry } from "../registry-empty.js";
|
||||
import { setActivePluginRegistry } from "../runtime.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
async function waitForPluginEventHandlers(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -38,6 +39,52 @@ describe("plugin run context lifecycle", () => {
|
||||
resetAgentEventsForTest();
|
||||
});
|
||||
|
||||
it("blocks stale plugin API run-context mutations after registry replacement", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
let capturedApi: OpenClawPluginApi | undefined;
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "stale-run-context-plugin",
|
||||
name: "Stale Run Context Plugin",
|
||||
}),
|
||||
register(api) {
|
||||
capturedApi = api;
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
|
||||
expect(
|
||||
capturedApi?.setRunContext({
|
||||
runId: "stale-run",
|
||||
namespace: "state",
|
||||
value: { stale: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getPluginRunContext({
|
||||
pluginId: "stale-run-context-plugin",
|
||||
get: { runId: "stale-run", namespace: "state" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
setPluginRunContext({
|
||||
pluginId: "stale-run-context-plugin",
|
||||
patch: { runId: "stale-run", namespace: "state", value: { live: true } },
|
||||
}),
|
||||
).toBe(true);
|
||||
capturedApi?.clearRunContext({ runId: "stale-run", namespace: "state" });
|
||||
expect(
|
||||
getPluginRunContext({
|
||||
pluginId: "stale-run-context-plugin",
|
||||
get: { runId: "stale-run", namespace: "state" },
|
||||
}),
|
||||
).toEqual({ live: true });
|
||||
});
|
||||
|
||||
it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => {
|
||||
let releaseToolHandler: (() => void) | undefined;
|
||||
let delayedToolHandlerSawContext: unknown;
|
||||
|
||||
13
src/plugins/registry-lifecycle.ts
Normal file
13
src/plugins/registry-lifecycle.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
|
||||
const retiredRegistries = new WeakSet<PluginRegistry>();
|
||||
|
||||
export function markPluginRegistryRetired(registry: PluginRegistry | null | undefined): void {
|
||||
if (registry) {
|
||||
retiredRegistries.add(registry);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginRegistryRetired(registry: PluginRegistry): boolean {
|
||||
return retiredRegistries.has(registry);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
} from "./memory-state.js";
|
||||
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
||||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import type {
|
||||
PluginCliBackendRegistration,
|
||||
PluginCliRegistration,
|
||||
@@ -303,6 +304,9 @@ const activePluginHookRegistrations = resolveGlobalSingleton<
|
||||
|
||||
type HookRegistration = { event: string; handler: Parameters<typeof registerInternalHook>[1] };
|
||||
type HookRollbackEntry = { name: string; previousRegistrations: HookRegistration[] };
|
||||
type PluginSideEffectGuard = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type PluginRegistrationCapabilities = {
|
||||
/** Broad registry writes that discovery and live activation both need. */
|
||||
@@ -338,6 +342,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const coreGatewayMethods = new Set(coreGatewayMethodNames);
|
||||
const pluginHookRollback = new Map<string, HookRollbackEntry[]>();
|
||||
const pluginsWithChannelRegistrationConflict = new Set<string>();
|
||||
const pluginSideEffectGuards = new Map<string, Set<PluginSideEffectGuard>>();
|
||||
|
||||
const pushDiagnostic = (diag: PluginDiagnostic) => {
|
||||
registry.diagnostics.push(diag);
|
||||
@@ -354,6 +359,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
return value;
|
||||
};
|
||||
|
||||
const createPluginSideEffectGuard = (pluginId: string): PluginSideEffectGuard => {
|
||||
const guard = { active: true };
|
||||
const guards = pluginSideEffectGuards.get(pluginId) ?? new Set<PluginSideEffectGuard>();
|
||||
guards.add(guard);
|
||||
pluginSideEffectGuards.set(pluginId, guards);
|
||||
return guard;
|
||||
};
|
||||
|
||||
const deactivatePluginSideEffectGuards = (pluginId: string): void => {
|
||||
const guards = pluginSideEffectGuards.get(pluginId);
|
||||
if (!guards) {
|
||||
return;
|
||||
}
|
||||
for (const guard of guards) {
|
||||
guard.active = false;
|
||||
}
|
||||
pluginSideEffectGuards.delete(pluginId);
|
||||
};
|
||||
|
||||
const registerCodexAppServerExtensionFactory = (
|
||||
record: PluginRecord,
|
||||
factory: Parameters<OpenClawPluginApi["registerCodexAppServerExtensionFactory"]>[0],
|
||||
@@ -2169,6 +2193,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registrationMode = params.registrationMode ?? "full";
|
||||
const registrationCapabilities = resolvePluginRegistrationCapabilities(registrationMode);
|
||||
pluginRuntimeRecordById.set(record.id, record);
|
||||
const sideEffectGuard = createPluginSideEffectGuard(record.id);
|
||||
const shouldCommitWorkflowSideEffect = () =>
|
||||
sideEffectGuard.active &&
|
||||
!isPluginRegistryRetired(registry) &&
|
||||
registry.plugins.some((plugin) => plugin.id === record.id && plugin.status === "loaded");
|
||||
return buildPluginApi({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -2378,14 +2407,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerRuntimeLifecycle: (lifecycle) => registerRuntimeLifecycle(record, lifecycle),
|
||||
registerAgentEventSubscription: (subscription) =>
|
||||
registerAgentEventSubscription(record, subscription),
|
||||
setRunContext: (patch) => setPluginRunContext({ pluginId: record.id, patch }),
|
||||
setRunContext: (patch) =>
|
||||
registryParams.activateGlobalSideEffects !== false &&
|
||||
shouldCommitWorkflowSideEffect()
|
||||
? setPluginRunContext({ pluginId: record.id, patch })
|
||||
: false,
|
||||
getRunContext: (get) => getPluginRunContext({ pluginId: record.id, get }),
|
||||
clearRunContext: (params) =>
|
||||
clearRunContext: (params) => {
|
||||
if (
|
||||
registryParams.activateGlobalSideEffects === false ||
|
||||
!shouldCommitWorkflowSideEffect()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearPluginRunContext({
|
||||
pluginId: record.id,
|
||||
runId: params.runId,
|
||||
namespace: params.namespace,
|
||||
}),
|
||||
});
|
||||
},
|
||||
registerSessionSchedulerJob: (job) => registerSessionSchedulerJob(record, job),
|
||||
registerMemoryCapability: (capability) => {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
@@ -2548,6 +2588,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
};
|
||||
|
||||
const rollbackPluginGlobalSideEffects = (pluginId: string) => {
|
||||
deactivatePluginSideEffectGuards(pluginId);
|
||||
if (registryParams.activateGlobalSideEffects === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
dispatchPluginAgentEventSubscriptions,
|
||||
} from "./host-hook-runtime.js";
|
||||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { markPluginRegistryRetired } from "./registry-lifecycle.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
import {
|
||||
PLUGIN_REGISTRY_STATE,
|
||||
@@ -129,6 +130,9 @@ export function setActivePluginRegistry(
|
||||
workspaceDir?: string,
|
||||
) {
|
||||
const previousRegistry = asPluginRegistry(state.activeRegistry);
|
||||
if (previousRegistry && previousRegistry !== registry) {
|
||||
markPluginRegistryRetired(previousRegistry);
|
||||
}
|
||||
state.activeRegistry = registry;
|
||||
state.activeVersion += 1;
|
||||
syncTrackedSurface(state.httpRoute, registry, true);
|
||||
|
||||
Reference in New Issue
Block a user