From 0d23c3b4e1335d2d68bbca1d8d71645db821aac2 Mon Sep 17 00:00:00 2001 From: Sliverp <38134380+sliverp@users.noreply.github.com> Date: Tue, 26 May 2026 11:01:39 +0800 Subject: [PATCH] fix: make QQ Bot media paths respect `OPENCLAW_HOME` configuration (#85309) * fix: make QQ Bot media paths respect `OPENCLAW_HOME` configuration * docs(changelog): note QQ Bot OPENCLAW_HOME media fix (#83562) --- CHANGELOG.md | 1 + .../qqbot/src/engine/utils/platform.test.ts | 146 ++++++++++++++++++ extensions/qqbot/src/engine/utils/platform.ts | 83 ++++++++-- 3 files changed, 217 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ade927e6ac9..8365e39fc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `` sends no longer silently fail when `HOME` and `OPENCLAW_HOME` differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp. - Update: report the primary malformed `openclaw.extensions` payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant. - Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh. - Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant. diff --git a/extensions/qqbot/src/engine/utils/platform.test.ts b/extensions/qqbot/src/engine/utils/platform.test.ts index ed4f64a392f..a77ebbfeaab 100644 --- a/extensions/qqbot/src/engine/utils/platform.test.ts +++ b/extensions/qqbot/src/engine/utils/platform.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getHomeDir, + getQQBotDataPath, + getQQBotMediaPath, resolveQQBotLocalMediaPath, resolveQQBotPayloadLocalFilePath, } from "./platform.js"; @@ -146,3 +148,147 @@ describe("qqbot local media path remapping", () => { expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile)); }); }); + +// Regression coverage for https://github.com/openclaw/openclaw/issues/83562 — +// when HOME and OPENCLAW_HOME diverge (Docker, multi-user hosts), QQ Bot media +// paths must be anchored on OPENCLAW_HOME so files written under +// `$OPENCLAW_HOME/.openclaw/media/qqbot/` are accepted by the outbound +// allowlist. +// +// Tests intentionally do NOT mock `os.homedir()` — the helper reads it via +// `import * as os from "node:os"` which `vi.spyOn` cannot reliably intercept +// across the ESM/CJS interop boundary. Instead each test treats the real OS +// home as the baseline and only varies `process.env.OPENCLAW_HOME`. +describe("qqbot media path resolution honors OPENCLAW_HOME (#83562)", () => { + const tempPaths: string[] = []; + const realOsHome = getHomeDir(); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const target of tempPaths.splice(0)) { + fs.rmSync(target, { recursive: true, force: true }); + } + }); + + function makeFakeOpenclawHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-oc-home-")); + tempPaths.push(dir); + return dir; + } + + it("accepts files under $OPENCLAW_HOME/.openclaw/media/qqbot when OPENCLAW_HOME differs from HOME", () => { + const fakeOpenclawHome = makeFakeOpenclawHome(); + // Sanity: the fake OPENCLAW_HOME must not be a subpath of the real OS home, + // otherwise the test would pass for the wrong reason on hosts where + // `os.tmpdir()` happens to live under `$HOME`. + expect(fakeOpenclawHome.startsWith(realOsHome)).toBe(false); + vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); + + const mediaFile = path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot", "repro.png"); + fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); + fs.writeFileSync(mediaFile, "image", "utf8"); + + expect(getQQBotMediaPath()).toBe(path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot")); + expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); + }); + + it("expands tilde-prefixed OPENCLAW_HOME against the OS home", () => { + // Use a unique subdirectory name so we can clean it up safely without + // touching anything that exists under the real home. + const sub = `qqbot-tilde-${process.pid}-${Date.now()}`; + const expectedHome = path.join(realOsHome, sub); + tempPaths.push(expectedHome); + vi.stubEnv("OPENCLAW_HOME", `~/${sub}`); + + expect(getQQBotMediaPath()).toBe(path.join(expectedHome, ".openclaw", "media", "qqbot")); + + const mediaFile = path.join(expectedHome, ".openclaw", "media", "qqbot", "tilde.png"); + fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); + fs.writeFileSync(mediaFile, "image", "utf8"); + + expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); + }); + + it("falls back to OS home when OPENCLAW_HOME is unset (no regression)", () => { + vi.stubEnv("OPENCLAW_HOME", ""); + + expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot")); + }); + + it("treats sentinel strings 'undefined' and 'null' as unset", () => { + for (const sentinel of ["undefined", "null"]) { + vi.stubEnv("OPENCLAW_HOME", sentinel); + expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot")); + } + }); + + it("keeps persisted QQ Bot data anchored on the OS home (compatibility)", () => { + const fakeOpenclawHome = makeFakeOpenclawHome(); + vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); + + // Persisted state (sessions, known users, refs) must NOT migrate when an + // operator adds OPENCLAW_HOME — otherwise existing deployments would lose + // their session state. Only the media root follows OPENCLAW_HOME. + expect(getQQBotDataPath()).toBe(path.join(realOsHome, ".openclaw", "qqbot")); + }); + + it("rejects files that live under HOME tree when OPENCLAW_HOME is the active root", () => { + const fakeOpenclawHome = makeFakeOpenclawHome(); + vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); + + // File under the HOME-side mirror — exactly the path that *worked* on + // current main and *broke* the OPENCLAW_HOME setup. After the fix the + // active media root is OPENCLAW_HOME, so a file under HOME is no longer + // implicitly allowed unless it remaps via the existing workspace fallback. + // Use a unique subdirectory so we never collide with real user media. + const stale = `qqbot-stale-${process.pid}-${Date.now()}.png`; + const homeOnlyFile = path.join(realOsHome, ".openclaw", "media", "qqbot", stale); + tempPaths.push(homeOnlyFile); + fs.mkdirSync(path.dirname(homeOnlyFile), { recursive: true }); + fs.writeFileSync(homeOnlyFile, "image", "utf8"); + + expect(resolveQQBotPayloadLocalFilePath(homeOnlyFile)).toBeNull(); + }); + + it("remaps workspace paths under either HOME or OPENCLAW_HOME to the OPENCLAW_HOME media root", () => { + const fakeOpenclawHome = makeFakeOpenclawHome(); + vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); + + const baseName = `remap-${process.pid}-${Date.now()}`; + + // Real file lives under the OPENCLAW_HOME media tree. + const mediaFile = path.join( + fakeOpenclawHome, + ".openclaw", + "media", + "qqbot", + "downloads", + baseName, + "remap.png", + ); + fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); + fs.writeFileSync(mediaFile, "image", "utf8"); + + // Agent that only knows the HOME-relative workspace path should still + // resolve to the real file thanks to the dual-tree workspace fallback. + const homeWorkspaceDir = path.join(realOsHome, ".openclaw", "workspace", "qqbot"); + const homeWorkspacePath = path.join(homeWorkspaceDir, "downloads", baseName, "remap.png"); + // Track for cleanup; we only created the unique baseName subdir indirectly + // through resolveQQBotLocalMediaPath, which does NOT actually create the + // HOME-side path, so nothing to clean up there beyond the OPENCLAW_HOME tree. + expect(resolveQQBotLocalMediaPath(homeWorkspacePath)).toBe(mediaFile); + + // Same path but under OPENCLAW_HOME should also remap. + const openclawWorkspacePath = path.join( + fakeOpenclawHome, + ".openclaw", + "workspace", + "qqbot", + "downloads", + baseName, + "remap.png", + ); + expect(resolveQQBotLocalMediaPath(openclawWorkspacePath)).toBe(mediaFile); + }); +}); diff --git a/extensions/qqbot/src/engine/utils/platform.ts b/extensions/qqbot/src/engine/utils/platform.ts index 74fdce1289a..4ee4f0f5c55 100644 --- a/extensions/qqbot/src/engine/utils/platform.ts +++ b/extensions/qqbot/src/engine/utils/platform.ts @@ -14,12 +14,17 @@ import { formatErrorMessage } from "./format.js"; import { debugLog, debugWarn } from "./log.js"; /** - * Resolve the current user's home directory safely across platforms. + * Resolve the current user's OS home directory safely across platforms. * * Priority: * 1. `os.homedir()` * 2. `$HOME` or `%USERPROFILE%` * 3. PlatformAdapter.getTempDir() as a last resort + * + * This is the *operating-system* home and intentionally ignores + * `OPENCLAW_HOME`. Persistent QQ Bot data (sessions, known users, refs) is + * keyed on this value to keep upgrades from hiding existing state when an + * operator later sets `OPENCLAW_HOME`. */ export function getHomeDir(): string { try { @@ -39,7 +44,46 @@ export function getHomeDir(): string { return getPlatformAdapter().getTempDir(); } -/** Return a path under `~/.openclaw/qqbot` without creating it. */ +/** + * Resolve the effective OpenClaw home directory. + * + * Mirrors the contract from core (`src/infra/home-dir.ts::resolveEffectiveHomeDir`) + * so QQ Bot media roots live under the same tree the rest of OpenClaw treats as + * `~`. The extension cannot import the core helper directly (it is a separate + * package with `openclaw` as a peer dependency), so this re-implements the + * minimal contract: + * + * 1. `OPENCLAW_HOME` when set (with `~` / `~/...` expanded against the OS home). + * 2. Otherwise fall back to {@link getHomeDir} so existing single-home + * deployments are unaffected. + * + * Empty / `"undefined"` / `"null"` strings are treated as unset to match how + * core normalizes the variable. + */ +function resolveOpenClawHome(): string { + const raw = process.env.OPENCLAW_HOME?.trim(); + if (!raw || raw === "undefined" || raw === "null") { + return getHomeDir(); + } + + if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) { + const osHome = getHomeDir(); + if (raw === "~") { + return osHome; + } + return path.join(osHome, raw.slice(2)); + } + + return raw; +} + +/** + * Return a path under `~/.openclaw/qqbot` without creating it. + * + * Anchored on the OS home (not `OPENCLAW_HOME`) so persisted QQ Bot data + * (sessions, known users, ref index, credential backups) does not silently + * disappear when an operator adds `OPENCLAW_HOME` after the fact. + */ export function getQQBotDataPath(...subPaths: string[]): string { return path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths); } @@ -54,16 +98,19 @@ export function getQQBotDataDir(...subPaths: string[]): string { } /** - * Return a path under `~/.openclaw/media/qqbot` without creating it. + * Return a path under `/.openclaw/media/qqbot` without creating it. * - * Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist so - * downloaded images and audio can be accessed by framework media tooling. + * Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist + * so downloaded images and audio can be accessed by framework media tooling. + * The base honors `OPENCLAW_HOME` (when set) so files written by agents into + * the OpenClaw-managed media tree are reachable by this plugin even when + * `HOME` and `OPENCLAW_HOME` differ (Docker, multi-user hosts). Fixes #83562. */ export function getQQBotMediaPath(...subPaths: string[]): string { - return path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths); + return path.join(resolveOpenClawHome(), ".openclaw", "media", "qqbot", ...subPaths); } -/** Return a path under `~/.openclaw/media/qqbot`, creating it on demand. */ +/** Return a path under `/.openclaw/media/qqbot`, creating it on demand. */ export function getQQBotMediaDir(...subPaths: string[]): string { const dir = getQQBotMediaPath(...subPaths); if (!fs.existsSync(dir)) { @@ -73,17 +120,18 @@ export function getQQBotMediaDir(...subPaths: string[]): string { } /** - * Return `~/.openclaw/media`, OpenClaw's shared media root. + * Return `/.openclaw/media`, OpenClaw's shared media root. * * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a * QQ Bot payload root lets the plugin trust framework-produced files that live * in sibling subdirectories such as `outbound/` (written by * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping - * the check anchored to a single, well-known directory. + * the check anchored to a single, well-known directory. Like + * {@link getQQBotMediaPath}, the base honors `OPENCLAW_HOME`. */ function getOpenClawMediaDir(): string { - return path.join(getHomeDir(), ".openclaw", "media"); + return path.join(resolveOpenClawHome(), ".openclaw", "media"); } // ---- Basic platform information ---- @@ -203,12 +251,21 @@ export function resolveQQBotLocalMediaPath(p: string): string { return normalized; } - const homeDir = getHomeDir(); + const osHomeDir = getHomeDir(); + const openclawHomeDir = resolveOpenClawHome(); const mediaRoot = getQQBotMediaPath(); const dataRoot = getQQBotDataPath(); - const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot"); + // When OPENCLAW_HOME differs from HOME we have to consider workspace roots + // under both trees: agents may be configured with `~`-relative paths (HOME) + // or with the OpenClaw-managed home tree. Deduplicate when they match. + const workspaceRoots = Array.from( + new Set([ + path.join(osHomeDir, ".openclaw", "workspace", "qqbot"), + path.join(openclawHomeDir, ".openclaw", "workspace", "qqbot"), + ]), + ); const candidateRoots = [ - { from: workspaceRoot, to: mediaRoot }, + ...workspaceRoots.map((from) => ({ from, to: mediaRoot })), { from: dataRoot, to: mediaRoot }, { from: mediaRoot, to: dataRoot }, ];