diff --git a/CHANGELOG.md b/CHANGELOG.md index add14fa9623..cc2347c4289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- 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. - 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. diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 93abe7cbfa9..6b421d68512 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -62,6 +62,10 @@ type ActiveZaloListener = { const activeListeners = new Map(); const groupContextCache = new Map(); +type ApiTypingCapability = { + sendTypingEvent: (threadId: string, type?: ThreadType) => Promise; +}; + type StoredZaloCredentials = { imei: string; cookie: Credentials["cookie"]; @@ -883,7 +887,15 @@ export async function sendZaloTypingEvent( } const api = await ensureApi(profile); 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 { + const info = await api.fetchAccountInfo(); + const profile = "profile" in info ? info.profile : info; + return toNumberId(profile.userId); } export async function sendZaloReaction(params: { @@ -1229,7 +1241,7 @@ export async function startZaloListener(params: { } const api = await ensureApi(profile); - const ownUserId = toNumberId(api.getOwnId()); + const ownUserId = await resolveOwnUserId(api); let stopped = false; const cleanup = () => { diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 3457b7ba7ec..4814246e1a4 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -21,7 +21,7 @@ export function resolveBundledPluginSources(params: { if (candidate.origin !== "bundled") { continue; } - const manifest = loadPluginManifest(candidate.rootDir); + const manifest = loadPluginManifest(candidate.rootDir, false); if (!manifest.ok) { continue; } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 37d63714099..5d4fb48c6bf 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -225,12 +225,13 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean { return false; } -function readPackageManifest(dir: string): PackageManifest | null { +function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { const manifestPath = path.join(dir, "package.json"); const opened = openBoundaryFileSync({ absolutePath: manifestPath, rootPath: dir, boundaryLabel: "plugin package directory", + rejectHardlinks, }); if (!opened.ok) { return null; @@ -318,12 +319,14 @@ function resolvePackageEntrySource(params: { entryPath: string; sourceLabel: string; diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; }): string | null { const source = path.resolve(params.packageDir, params.entryPath); const opened = openBoundaryFileSync({ absolutePath: source, rootPath: params.packageDir, boundaryLabel: "plugin package directory", + rejectHardlinks: params.rejectHardlinks ?? true, }); if (!opened.ok) { params.diagnostics.push({ @@ -387,7 +390,8 @@ function discoverInDirectory(params: { continue; } - const manifest = readPackageManifest(fullPath); + const rejectHardlinks = params.origin !== "bundled"; + const manifest = readPackageManifest(fullPath, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; @@ -398,6 +402,7 @@ function discoverInDirectory(params: { entryPath: extPath, sourceLabel: fullPath, diagnostics: params.diagnostics, + rejectHardlinks, }); if (!resolved) { continue; @@ -488,7 +493,8 @@ function discoverFromPath(params: { } if (stat.isDirectory()) { - const manifest = readPackageManifest(resolved); + const rejectHardlinks = params.origin !== "bundled"; + const manifest = readPackageManifest(resolved, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; @@ -499,6 +505,7 @@ function discoverFromPath(params: { entryPath: extPath, sourceLabel: resolved, diagnostics: params.diagnostics, + rejectHardlinks, }); if (!source) { continue; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 48c51a0e137..d9b31fe8a4b 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -922,6 +922,58 @@ describe("loadOpenClawPlugins", () => { 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", () => { const { root, distFile } = createPluginSdkAliasFixture(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index deb0fa02cd3..a2d2f50b6ac 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -538,9 +538,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi absolutePath: candidate.source, rootPath: pluginRoot, boundaryLabel: "plugin root", - // Discovery stores rootDir as realpath but source may still be a lexical alias - // (e.g. /var/... vs /private/var/... on macOS). Canonical boundary checks - // still enforce containment; skip lexical pre-check to avoid false escapes. + rejectHardlinks: candidate.origin !== "bundled", skipLexicalRootCheck: true, }); if (!opened.ok) { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 356ca1f2074..54e0e9c2b5b 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -233,4 +233,40 @@ describe("loadPluginManifestRegistry", () => { registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")), ).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); + }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ea93e2d5725..6176f9ee18f 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -167,7 +167,8 @@ export function loadPluginManifestRegistry(params: { const realpathCache = new Map(); for (const candidate of candidates) { - const manifestRes = loadPluginManifest(candidate.rootDir); + const rejectHardlinks = candidate.origin !== "bundled"; + const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks); if (!manifestRes.ok) { diagnostics.push({ level: "error", diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0e01a223178..3a3abe0a620 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -42,12 +42,16 @@ export function resolvePluginManifestPath(rootDir: string): string { 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 opened = openBoundaryFileSync({ absolutePath: manifestPath, rootPath: rootDir, boundaryLabel: "plugin root", + rejectHardlinks, }); if (!opened.ok) { if (opened.reason === "path") {