mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:00:42 +00:00
fix(plugins): detect stale persisted registry metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user