Files
openclaw/extensions/openshell/index.test.ts
Peter Steinberger 430d2caa56 fix(sandbox): retire plugin backends with registry lifecycle (#130662)
* fix(openshell): unregister sandbox backend on plugin stop

Bind the explicit backend disposer to the plugin service lifetime so disabling or reloading OpenShell retires its factory, manager, and workdir hooks together. Preserve the core registry's stale and repeated disposer semantics.

* fix(sandbox): retire plugin backends with registry lifecycle
2026-08-27 12:54:25 -07:00

186 lines
6.5 KiB
TypeScript

import type {
OpenClawPluginApi,
OpenClawPluginService,
PluginRuntimeLifecycleRegistration,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { createPluginRegistryFixture } from "openclaw/plugin-sdk/plugin-test-contracts";
import {
createEmptyPluginRegistry,
createPluginRecord,
getActivePluginRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import {
getSandboxBackendFactory,
getSandboxBackendManager,
getSandboxBackendWorkdirResolver,
type CreateSandboxBackendParams,
} from "openclaw/plugin-sdk/sandbox";
import { afterEach, describe, expect, it } from "vitest";
import plugin from "./index.js";
import { createOpenShellBackendSandboxConfig } from "./src/openshell.test-support.js";
function readBackend() {
return {
factory: getSandboxBackendFactory("openshell"),
manager: getSandboxBackendManager("openshell"),
resolveWorkdir: getSandboxBackendWorkdirResolver("openshell"),
};
}
const workdirParams: CreateSandboxBackendParams = {
sessionKey: "agent:openshell-lifecycle:main",
scopeKey: "agent:openshell-lifecycle:main",
workspaceDir: "/tmp/openclaw-openshell-lifecycle/workspace",
agentWorkspaceDir: "/tmp/openclaw-openshell-lifecycle/workspace",
cfg: createOpenShellBackendSandboxConfig(),
};
describe("OpenShell plugin registration lifecycle", () => {
const stops: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const stop of stops.splice(0).toReversed()) {
await stop();
}
});
function registerGeneration(remoteWorkspaceDir: string) {
const lifecycles: PluginRuntimeLifecycleRegistration[] = [];
const api = createTestPluginApi({
id: "openshell",
pluginConfig: { remoteWorkspaceDir },
registerRuntimeLifecycle: (lifecycle) => lifecycles.push(lifecycle),
});
plugin.register(api);
const cleanup = async (
context: Parameters<NonNullable<PluginRuntimeLifecycleRegistration["cleanup"]>>[0],
) => {
for (const lifecycle of lifecycles.toReversed()) {
await lifecycle.cleanup?.(context);
}
};
const stop = () => cleanup({ reason: "disable" });
stops.push(stop);
return { backend: readBackend(), cleanup, stop };
}
it.each(["disable", "restart"] as const)(
"restores eager backend hooks on global %s",
async (reason) => {
const original = readBackend();
for (const remoteWorkspaceDir of ["/sandbox/first", "/sandbox/second", "/agent/third"]) {
const generation = registerGeneration(remoteWorkspaceDir);
expect(generation.backend.factory).toEqual(expect.any(Function));
expect(generation.backend.manager).toEqual({
describeRuntime: expect.any(Function),
removeRuntime: expect.any(Function),
});
expect(generation.backend.resolveWorkdir?.(workdirParams)).toBe(remoteWorkspaceDir);
await generation.cleanup({ reason });
expect(readBackend()).toEqual(original);
await generation.stop();
expect(readBackend()).toEqual(original);
}
},
);
it.each(["disable", "restart", "reset", "delete"] as const)(
"preserves global backend hooks during scoped %s cleanup",
async (reason) => {
const generation = registerGeneration("/sandbox/scoped");
for (const scope of [
{ sessionKey: "agent:other:main" },
{ runId: "other-run" },
{ sessionKey: "" },
{ runId: "" },
]) {
await generation.cleanup({ reason, ...scope });
expect(readBackend()).toEqual(generation.backend);
}
if (reason === "reset" || reason === "delete") {
await generation.cleanup({ reason });
expect(readBackend()).toEqual(generation.backend);
}
},
);
it.each(["older-first", "newer-first"] as const)(
"preserves the live backend when plugin generations stop %s",
async (order) => {
const original = readBackend();
const older = registerGeneration("/sandbox/older");
const newer = registerGeneration("/sandbox/newer");
expect(readBackend()).toEqual(newer.backend);
const first = order === "older-first" ? older : newer;
const last = order === "older-first" ? newer : older;
await first.stop();
expect(readBackend()).toEqual(last.backend);
await last.stop();
expect(readBackend()).toEqual(original);
await first.stop();
expect(readBackend()).toEqual(original);
},
);
it.each([
"discovery",
"tool-discovery",
"setup-only",
"setup-runtime",
"cli-metadata",
] satisfies OpenClawPluginApi["registrationMode"][])(
"does not register runtime hooks or services in %s mode",
(registrationMode) => {
const original = readBackend();
const services: OpenClawPluginService[] = [];
const lifecycles: PluginRuntimeLifecycleRegistration[] = [];
plugin.register(
createTestPluginApi({
registrationMode,
pluginConfig: { remoteWorkspaceDir: "/outside-managed-roots" },
registerService: (service) => services.push(service),
registerRuntimeLifecycle: (lifecycle) => lifecycles.push(lifecycle),
}),
);
expect(services).toEqual([]);
expect(lifecycles).toEqual([]);
expect(readBackend()).toEqual(original);
},
);
it("retires a registered backend even when no plugin services ever start", async () => {
const originalBackend = readBackend();
const originalRegistry = getActivePluginRegistry();
const { registry } = createPluginRegistryFixture();
const record = createPluginRecord({ id: "openshell" });
registry.registry.plugins.push(record);
plugin.register(
registry.createApi(record, {
config: {},
pluginConfig: { remoteWorkspaceDir: "/sandbox/unstarted" },
}),
);
try {
expect(readBackend().factory).toEqual(expect.any(Function));
expect(readBackend().resolveWorkdir?.(workdirParams)).toBe("/sandbox/unstarted");
setActivePluginRegistry(registry.registry);
setActivePluginRegistry(createEmptyPluginRegistry());
await expect.poll(readBackend).toEqual(originalBackend);
} finally {
for (const { lifecycle } of registry.registry.runtimeLifecycles.toReversed()) {
await lifecycle.cleanup?.({ reason: "disable" });
}
if (originalRegistry) {
setActivePluginRegistry(originalRegistry);
} else {
resetPluginRuntimeStateForTest();
}
}
});
});