mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:34:44 +00:00
fix(plugins): allow hardlinks for bundled plugins (fixes #28175, #28404) (openclaw#32119) thanks @markfietje
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: markfietje <4325889+markfietje@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||||
|
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ type ActiveZaloListener = {
|
|||||||
const activeListeners = new Map<string, ActiveZaloListener>();
|
const activeListeners = new Map<string, ActiveZaloListener>();
|
||||||
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
|
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
|
||||||
|
|
||||||
|
type ApiTypingCapability = {
|
||||||
|
sendTypingEvent: (threadId: string, type?: ThreadType) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
type StoredZaloCredentials = {
|
type StoredZaloCredentials = {
|
||||||
imei: string;
|
imei: string;
|
||||||
cookie: Credentials["cookie"];
|
cookie: Credentials["cookie"];
|
||||||
@@ -883,7 +887,15 @@ export async function sendZaloTypingEvent(
|
|||||||
}
|
}
|
||||||
const api = await ensureApi(profile);
|
const api = await ensureApi(profile);
|
||||||
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
||||||
await api.sendTypingEvent(trimmedThreadId, type);
|
if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
|
||||||
|
await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOwnUserId(api: API): Promise<string> {
|
||||||
|
const info = await api.fetchAccountInfo();
|
||||||
|
const profile = "profile" in info ? info.profile : info;
|
||||||
|
return toNumberId(profile.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendZaloReaction(params: {
|
export async function sendZaloReaction(params: {
|
||||||
@@ -1229,7 +1241,7 @@ export async function startZaloListener(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = await ensureApi(profile);
|
const api = await ensureApi(profile);
|
||||||
const ownUserId = toNumberId(api.getOwnId());
|
const ownUserId = await resolveOwnUserId(api);
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function resolveBundledPluginSources(params: {
|
|||||||
if (candidate.origin !== "bundled") {
|
if (candidate.origin !== "bundled") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const manifest = loadPluginManifest(candidate.rootDir);
|
const manifest = loadPluginManifest(candidate.rootDir, false);
|
||||||
if (!manifest.ok) {
|
if (!manifest.ok) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,12 +225,13 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPackageManifest(dir: string): PackageManifest | null {
|
function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null {
|
||||||
const manifestPath = path.join(dir, "package.json");
|
const manifestPath = path.join(dir, "package.json");
|
||||||
const opened = openBoundaryFileSync({
|
const opened = openBoundaryFileSync({
|
||||||
absolutePath: manifestPath,
|
absolutePath: manifestPath,
|
||||||
rootPath: dir,
|
rootPath: dir,
|
||||||
boundaryLabel: "plugin package directory",
|
boundaryLabel: "plugin package directory",
|
||||||
|
rejectHardlinks,
|
||||||
});
|
});
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
return null;
|
return null;
|
||||||
@@ -318,12 +319,14 @@ function resolvePackageEntrySource(params: {
|
|||||||
entryPath: string;
|
entryPath: string;
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
diagnostics: PluginDiagnostic[];
|
diagnostics: PluginDiagnostic[];
|
||||||
|
rejectHardlinks?: boolean;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const source = path.resolve(params.packageDir, params.entryPath);
|
const source = path.resolve(params.packageDir, params.entryPath);
|
||||||
const opened = openBoundaryFileSync({
|
const opened = openBoundaryFileSync({
|
||||||
absolutePath: source,
|
absolutePath: source,
|
||||||
rootPath: params.packageDir,
|
rootPath: params.packageDir,
|
||||||
boundaryLabel: "plugin package directory",
|
boundaryLabel: "plugin package directory",
|
||||||
|
rejectHardlinks: params.rejectHardlinks ?? true,
|
||||||
});
|
});
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
params.diagnostics.push({
|
params.diagnostics.push({
|
||||||
@@ -387,7 +390,8 @@ function discoverInDirectory(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = readPackageManifest(fullPath);
|
const rejectHardlinks = params.origin !== "bundled";
|
||||||
|
const manifest = readPackageManifest(fullPath, rejectHardlinks);
|
||||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||||
|
|
||||||
@@ -398,6 +402,7 @@ function discoverInDirectory(params: {
|
|||||||
entryPath: extPath,
|
entryPath: extPath,
|
||||||
sourceLabel: fullPath,
|
sourceLabel: fullPath,
|
||||||
diagnostics: params.diagnostics,
|
diagnostics: params.diagnostics,
|
||||||
|
rejectHardlinks,
|
||||||
});
|
});
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
continue;
|
continue;
|
||||||
@@ -488,7 +493,8 @@ function discoverFromPath(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const manifest = readPackageManifest(resolved);
|
const rejectHardlinks = params.origin !== "bundled";
|
||||||
|
const manifest = readPackageManifest(resolved, rejectHardlinks);
|
||||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||||
|
|
||||||
@@ -499,6 +505,7 @@ function discoverFromPath(params: {
|
|||||||
entryPath: extPath,
|
entryPath: extPath,
|
||||||
sourceLabel: resolved,
|
sourceLabel: resolved,
|
||||||
diagnostics: params.diagnostics,
|
diagnostics: params.diagnostics,
|
||||||
|
rejectHardlinks,
|
||||||
});
|
});
|
||||||
if (!source) {
|
if (!source) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -922,6 +922,58 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
|
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows bundled plugin entry files that are hardlinked aliases", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bundledDir = makeTempDir();
|
||||||
|
const pluginDir = path.join(bundledDir, "hardlinked-bundled");
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
|
||||||
|
const outsideDir = makeTempDir();
|
||||||
|
const outsideEntry = path.join(outsideDir, "outside.cjs");
|
||||||
|
fs.writeFileSync(
|
||||||
|
outsideEntry,
|
||||||
|
'module.exports = { id: "hardlinked-bundled", register() {} };',
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const plugin = writePlugin({
|
||||||
|
id: "hardlinked-bundled",
|
||||||
|
body: 'module.exports = { id: "hardlinked-bundled", register() {} };',
|
||||||
|
dir: pluginDir,
|
||||||
|
filename: "index.cjs",
|
||||||
|
});
|
||||||
|
fs.rmSync(plugin.file);
|
||||||
|
try {
|
||||||
|
fs.linkSync(outsideEntry, plugin.file);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||||
|
const registry = loadOpenClawPlugins({
|
||||||
|
cache: false,
|
||||||
|
workspaceDir: bundledDir,
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"hardlinked-bundled": { enabled: true },
|
||||||
|
},
|
||||||
|
allow: ["hardlinked-bundled"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = registry.plugins.find((entry) => entry.id === "hardlinked-bundled");
|
||||||
|
expect(record?.status).toBe("loaded");
|
||||||
|
expect(registry.diagnostics.some((entry) => entry.message.includes("unsafe plugin path"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
||||||
const { root, distFile } = createPluginSdkAliasFixture();
|
const { root, distFile } = createPluginSdkAliasFixture();
|
||||||
|
|
||||||
|
|||||||
@@ -538,9 +538,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
absolutePath: candidate.source,
|
absolutePath: candidate.source,
|
||||||
rootPath: pluginRoot,
|
rootPath: pluginRoot,
|
||||||
boundaryLabel: "plugin root",
|
boundaryLabel: "plugin root",
|
||||||
// Discovery stores rootDir as realpath but source may still be a lexical alias
|
rejectHardlinks: candidate.origin !== "bundled",
|
||||||
// (e.g. /var/... vs /private/var/... on macOS). Canonical boundary checks
|
|
||||||
// still enforce containment; skip lexical pre-check to avoid false escapes.
|
|
||||||
skipLexicalRootCheck: true,
|
skipLexicalRootCheck: true,
|
||||||
});
|
});
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
|
|||||||
@@ -233,4 +233,40 @@ describe("loadPluginManifestRegistry", () => {
|
|||||||
registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")),
|
registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows bundled manifest paths that are hardlinked aliases", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rootDir = makeTempDir();
|
||||||
|
const outsideDir = makeTempDir();
|
||||||
|
const outsideManifest = path.join(outsideDir, "openclaw.plugin.json");
|
||||||
|
const linkedManifest = path.join(rootDir, "openclaw.plugin.json");
|
||||||
|
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default function () {}", "utf-8");
|
||||||
|
fs.writeFileSync(
|
||||||
|
outsideManifest,
|
||||||
|
JSON.stringify({ id: "bundled-hardlink", configSchema: { type: "object" } }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.linkSync(outsideManifest, linkedManifest);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = loadRegistry([
|
||||||
|
createPluginCandidate({
|
||||||
|
idHint: "bundled-hardlink",
|
||||||
|
rootDir,
|
||||||
|
origin: "bundled",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(registry.plugins.some((entry) => entry.id === "bundled-hardlink")).toBe(true);
|
||||||
|
expect(
|
||||||
|
registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ export function loadPluginManifestRegistry(params: {
|
|||||||
const realpathCache = new Map<string, string>();
|
const realpathCache = new Map<string, string>();
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const manifestRes = loadPluginManifest(candidate.rootDir);
|
const rejectHardlinks = candidate.origin !== "bundled";
|
||||||
|
const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks);
|
||||||
if (!manifestRes.ok) {
|
if (!manifestRes.ok) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
level: "error",
|
level: "error",
|
||||||
|
|||||||
@@ -42,12 +42,16 @@ export function resolvePluginManifestPath(rootDir: string): string {
|
|||||||
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
|
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
|
export function loadPluginManifest(
|
||||||
|
rootDir: string,
|
||||||
|
rejectHardlinks = true,
|
||||||
|
): PluginManifestLoadResult {
|
||||||
const manifestPath = resolvePluginManifestPath(rootDir);
|
const manifestPath = resolvePluginManifestPath(rootDir);
|
||||||
const opened = openBoundaryFileSync({
|
const opened = openBoundaryFileSync({
|
||||||
absolutePath: manifestPath,
|
absolutePath: manifestPath,
|
||||||
rootPath: rootDir,
|
rootPath: rootDir,
|
||||||
boundaryLabel: "plugin root",
|
boundaryLabel: "plugin root",
|
||||||
|
rejectHardlinks,
|
||||||
});
|
});
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
if (opened.reason === "path") {
|
if (opened.reason === "path") {
|
||||||
|
|||||||
Reference in New Issue
Block a user