diff --git a/CHANGELOG.md b/CHANGELOG.md index eb16f9c0076..d1e33fbdb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - 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. +- Plugins/registry: canonicalize install-record provenance paths before trust diagnostics, so npm plugins installed under symlinked temp/state roots no longer warn as untracked local code. - 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/plugins/loader-provenance.ts b/src/plugins/loader-provenance.ts index cddf50c7d85..bd958ac8148 100644 --- a/src/plugins/loader-provenance.ts +++ b/src/plugins/loader-provenance.ts @@ -3,7 +3,7 @@ import { resolveUserPath } from "../utils.js"; import type { PluginCandidate } from "./discovery.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; -import { isPathInside, safeStatSync } from "./path-safety.js"; +import { isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginRecord, PluginRegistry } from "./registry.js"; import type { PluginLogger } from "./types.js"; @@ -44,15 +44,16 @@ function addPathToMatcher( if (!resolved) { return; } - if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { + const canonical = safeRealpathSync(resolved) ?? resolved; + if (matcher.exact.has(canonical) || matcher.dirs.includes(canonical)) { return; } - const stat = safeStatSync(resolved); + const stat = safeStatSync(canonical); if (stat?.isDirectory()) { - matcher.dirs.push(resolved); + matcher.dirs.push(canonical); return; } - matcher.exact.add(resolved); + matcher.exact.add(canonical); } function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean { @@ -101,16 +102,17 @@ function isTrackedByProvenance(params: { env: NodeJS.ProcessEnv; }): boolean { const sourcePath = resolveUserPath(params.source, params.env); + const canonicalSourcePath = safeRealpathSync(sourcePath) ?? sourcePath; const installRule = params.index.installRules.get(params.pluginId); if (installRule) { if (installRule.trackedWithoutPaths) { return true; } - if (matchesPathMatcher(installRule.matcher, sourcePath)) { + if (matchesPathMatcher(installRule.matcher, canonicalSourcePath)) { return true; } } - return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); + return matchesPathMatcher(params.index.loadPathMatcher, canonicalSourcePath); } function matchesExplicitInstallRule(params: { @@ -120,11 +122,12 @@ function matchesExplicitInstallRule(params: { env: NodeJS.ProcessEnv; }): boolean { const sourcePath = resolveUserPath(params.source, params.env); + const canonicalSourcePath = safeRealpathSync(sourcePath) ?? sourcePath; const installRule = params.index.installRules.get(params.pluginId); if (!installRule || installRule.trackedWithoutPaths) { return false; } - return matchesPathMatcher(installRule.matcher, sourcePath); + return matchesPathMatcher(installRule.matcher, canonicalSourcePath); } function resolveCandidateDuplicateRank(params: { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 67da8248698..2836db62b22 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -6273,6 +6273,74 @@ module.exports = { }; }, }, + { + label: "does not warn when install paths resolve through a symlinked state root", + loadRegistry: () => { + useNoBundledPlugins(); + const stateDir = makeTempDir(); + const realHome = path.join(stateDir, "real-home"); + const linkedHome = path.join(stateDir, "linked-home"); + mkdirSafe(realHome); + fs.symlinkSync(realHome, linkedHome, process.platform === "win32" ? "junction" : "dir"); + + const pluginDir = path.join( + realHome, + ".openclaw", + "npm", + "node_modules", + "@example", + "tracked-symlink-install", + ); + mkdirSafe(pluginDir); + const plugin = writePlugin({ + id: "tracked-symlink-install", + body: simplePluginBody("tracked-symlink-install"), + dir: pluginDir, + filename: "index.cjs", + }); + writePersistedInstalledPluginIndexInstallRecordsSync( + { + [plugin.id]: { + source: "npm", + spec: "@example/tracked-symlink-install@1.0.0", + installPath: path.join( + linkedHome, + ".openclaw", + "npm", + "node_modules", + "@example", + "tracked-symlink-install", + ), + version: "1.0.0", + }, + }, + { stateDir }, + ); + + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: createWarningLogger(warnings), + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + config: { + plugins: { + enabled: true, + }, + }, + }); + + return { + registry, + warnings, + pluginId: plugin.id, + expectWarning: false, + }; + }, + }, ] as const; runScenarioCases(scenarios, (scenario) => {