fix: guard stale run context APIs

This commit is contained in:
Eva
2026-05-01 22:03:06 +07:00
committed by Josh Lehman
parent 85a9aabc56
commit 749cb8048f
4 changed files with 108 additions and 3 deletions

View File

@@ -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;

View 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);
}

View File

@@ -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;
}

View File

@@ -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);