mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 20:46:30 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user