fix(plugins): canonicalize install provenance paths

This commit is contained in:
Peter Steinberger
2026-05-02 23:31:48 +01:00
parent c8fa0fd1c9
commit dc005e1bcc
3 changed files with 80 additions and 8 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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) => {