diff --git a/CHANGELOG.md b/CHANGELOG.md index 242732f7d80..c6e378b7996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 1ef26dacde6..18e6e493d02 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -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", }); }); diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts index bdb44b49d5d..9e3623507f2 100644 --- a/src/agents/codex-app-server.extensions.test.ts +++ b/src/agents/codex-app-server.extensions.test.ts @@ -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: { diff --git a/src/plugins/agent-tool-result-middleware-loader.ts b/src/plugins/agent-tool-result-middleware-loader.ts index 647067327d8..3dca9dd28a6 100644 --- a/src/plugins/agent-tool-result-middleware-loader.ts +++ b/src/plugins/agent-tool-result-middleware-loader.ts @@ -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) { diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index e39165899fd..9d6ff5a4f97 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -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" }), + ); + }); }); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index dcbe0328f54..a4b999d5458 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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; });