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)
This commit is contained in:
Sliverp
2026-05-26 11:01:39 +08:00
committed by GitHub
parent a695c28bfb
commit 0d23c3b4e1
3 changed files with 217 additions and 13 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `<qqmedia>` 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.

View File

@@ -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);
});
});

View File

@@ -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-home>/.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-home>/.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-home>/.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 },
];