fix(plugins): detect stale persisted registry metadata

This commit is contained in:
Peter Steinberger
2026-05-02 22:34:53 +01:00
parent 00ad13b599
commit e5f8c5195f
6 changed files with 79 additions and 20 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc.
- Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc.
- Plugins/update: detect tracked plugin install records whose package directories disappeared during `openclaw update`, reinstall them before normal plugin updates, and fail the update if any install record still points at missing disk payloads.
- Plugins/registry: hash manifest and package metadata when validating persisted plugin registries so fast same-size rewrites cannot leave stale plugin metadata trusted.
- CLI/infer: reject local `codex/*` one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error.
- Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns.
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.

View File

@@ -797,6 +797,8 @@ describe("exec notifyOnExit", () => {
it("scopes notifyOnExit heartbeat wake to the exec session key", async () => {
await expectNotifyOnExitWake(createNotifyOnExitExecTool(), {
source: "exec-event",
intent: "event",
reason: "exec-event",
sessionKey: DEFAULT_NOTIFY_SESSION_KEY,
});
@@ -804,6 +806,8 @@ describe("exec notifyOnExit", () => {
it("keeps notifyOnExit heartbeat wake unscoped for non-agent session keys", async () => {
await expectNotifyOnExitWake(createNotifyOnExitExecTool({ sessionKey: "global" }), {
source: "exec-event",
intent: "event",
reason: "exec-event",
});
});

View File

@@ -70,6 +70,7 @@ describe("agent tool result middleware", () => {
},
},
},
onlyPluginIds: ["tool-result-middleware"],
};
loadOpenClawPlugins(options);
@@ -112,6 +113,7 @@ describe("agent tool result middleware", () => {
});
const registry = loadOpenClawPlugins({
onlyPluginIds: ["tool-result-middleware"],
config: {
plugins: {
entries: {
@@ -152,6 +154,7 @@ describe("agent tool result middleware", () => {
const registry = loadOpenClawPlugins({
workspaceDir: tmp,
onlyPluginIds: ["tool-result-middleware"],
config: {
plugins: {
load: { paths: [pluginFile] },
@@ -191,6 +194,7 @@ export default { id: "tool-result-middleware", register(api) {
});
loadOpenClawPlugins({
onlyPluginIds: ["tool-result-middleware"],
config: {
plugins: {
entries: {
@@ -290,6 +294,7 @@ describe("Codex app-server extension factories", () => {
},
},
},
onlyPluginIds: ["codex-ext"],
};
loadOpenClawPlugins(options);
@@ -331,6 +336,7 @@ describe("Codex app-server extension factories", () => {
const registry = loadOpenClawPlugins({
workspaceDir: tmp,
onlyPluginIds: ["codex-ext"],
config: {
plugins: {
load: { paths: [pluginFile] },
@@ -363,6 +369,7 @@ describe("Codex app-server extension factories", () => {
});
const registry = loadOpenClawPlugins({
onlyPluginIds: ["codex-ext"],
config: {
plugins: {
entries: {
@@ -404,6 +411,7 @@ describe("Codex app-server extension factories", () => {
});
const registry = loadOpenClawPlugins({
onlyPluginIds: ["codex-ext"],
config: {
plugins: {
entries: {

View File

@@ -9,6 +9,7 @@ import {
listAgentToolResultMiddlewares,
normalizeAgentToolResultMiddlewareRuntimeIds,
} from "./agent-tool-result-middleware.js";
import { loadOpenClawPlugins } from "./loader.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
const log = createSubsystemLogger("plugins/agent-tool-result-middleware");
@@ -72,11 +73,17 @@ export async function loadAgentToolResultMiddlewaresForRuntime(params: {
env,
requiredPluginIds: pluginIds,
});
if (!registry) {
return [];
}
const runtimeRegistry =
registry ??
loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: pluginIds,
activate: false,
});
return registry.agentToolResultMiddlewares
return runtimeRegistry.agentToolResultMiddlewares
.filter((entry) => entry.runtimes.includes(params.runtime))
.map((entry) => entry.handler);
} catch (error) {

View File

@@ -96,7 +96,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.diagnostics).toEqual([]);
});
it("keeps persisted package plugins on the fast path when file signatures match", () => {
it("keeps persisted package plugins when metadata still matches", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
@@ -113,22 +113,14 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(record?.packageJson?.fileSignature).toBeDefined();
writePersistedInstalledPluginIndexSync(index, { stateDir });
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
const pluginMetadataFileReads = readFileSyncSpy.mock.calls.filter((call) => {
const filePath = String(call[0]);
return (
filePath === path.join(rootDir, "openclaw.plugin.json") ||
filePath === path.join(rootDir, "package.json")
);
});
expect(result.source).toBe("persisted");
expect(pluginMetadataFileReads).toEqual([]);
expect(result.diagnostics).toEqual([]);
});
it("detects same-size same-mtime manifest replacements", () => {
@@ -197,4 +189,55 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
});
it("detects package.json replacements even when stored stat fields still match", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
},
};
writePackagePlugin(rootDir);
const index = loadInstalledPluginIndex({ config, env });
replaceFilePreservingSizeAndMtime(
path.join(rootDir, "package.json"),
JSON.stringify({ name: "demo", version: "1.0.1" }),
);
const stat = fs.statSync(path.join(rootDir, "package.json"));
const [plugin] = index.plugins;
if (!plugin?.packageJson) {
throw new Error("expected test plugin package metadata");
}
const stalePlugin = {
...plugin,
packageJson: {
...plugin.packageJson,
fileSignature: {
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
},
},
};
const staleIndex: InstalledPluginIndex = {
...index,
plugins: [stalePlugin, ...index.plugins.slice(1)],
};
writePersistedInstalledPluginIndexSync(staleIndex, { stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
});
});

View File

@@ -137,9 +137,7 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
plugin.manifestPath,
plugin.manifestFile,
);
if (manifestSignatureMatches === true) {
// Stored stat signature is unchanged; avoid hashing on startup.
} else if (manifestSignatureMatches === false) {
if (manifestSignatureMatches === false) {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
@@ -162,12 +160,10 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
packageJsonPath,
plugin.packageJson.fileSignature,
);
if (packageJsonSignatureMatches === true) {
return false;
}
if (packageJsonSignatureMatches === false) {
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
}
// Fast same-size rewrites can preserve observable stat fields on some filesystems.
const packageJsonHash = hashExistingFile(packageJsonPath);
return packageJsonHash !== plugin.packageJson.hash;
});