Skip to content

Commit 0d23c3b

Browse files
authored
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)
1 parent a695c28 commit 0d23c3b

3 files changed

Lines changed: 217 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121

2222
### Fixes
2323

24+
- 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.
2425
- Update: report the primary malformed `openclaw.extensions` payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.
2526
- Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.
2627
- Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant.

extensions/qqbot/src/engine/utils/platform.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import {
66
getHomeDir,
7+
getQQBotDataPath,
8+
getQQBotMediaPath,
79
resolveQQBotLocalMediaPath,
810
resolveQQBotPayloadLocalFilePath,
911
} from "./platform.js";
@@ -146,3 +148,147 @@ describe("qqbot local media path remapping", () => {
146148
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
147149
});
148150
});
151+
152+
// Regression coverage for https://github.com/openclaw/openclaw/issues/83562 —
153+
// when HOME and OPENCLAW_HOME diverge (Docker, multi-user hosts), QQ Bot media
154+
// paths must be anchored on OPENCLAW_HOME so files written under
155+
// `$OPENCLAW_HOME/.openclaw/media/qqbot/` are accepted by the outbound
156+
// allowlist.
157+
//
158+
// Tests intentionally do NOT mock `os.homedir()` — the helper reads it via
159+
// `import * as os from "node:os"` which `vi.spyOn` cannot reliably intercept
160+
// across the ESM/CJS interop boundary. Instead each test treats the real OS
161+
// home as the baseline and only varies `process.env.OPENCLAW_HOME`.
162+
describe("qqbot media path resolution honors OPENCLAW_HOME (#83562)", () => {
163+
const tempPaths: string[] = [];
164+
const realOsHome = getHomeDir();
165+
166+
afterEach(() => {
167+
vi.unstubAllEnvs();
168+
vi.restoreAllMocks();
169+
for (const target of tempPaths.splice(0)) {
170+
fs.rmSync(target, { recursive: true, force: true });
171+
}
172+
});
173+
174+
function makeFakeOpenclawHome(): string {
175+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-oc-home-"));
176+
tempPaths.push(dir);
177+
return dir;
178+
}
179+
180+
it("accepts files under $OPENCLAW_HOME/.openclaw/media/qqbot when OPENCLAW_HOME differs from HOME", () => {
181+
const fakeOpenclawHome = makeFakeOpenclawHome();
182+
// Sanity: the fake OPENCLAW_HOME must not be a subpath of the real OS home,
183+
// otherwise the test would pass for the wrong reason on hosts where
184+
// `os.tmpdir()` happens to live under `$HOME`.
185+
expect(fakeOpenclawHome.startsWith(realOsHome)).toBe(false);
186+
vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome);
187+
188+
const mediaFile = path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot", "repro.png");
189+
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
190+
fs.writeFileSync(mediaFile, "image", "utf8");
191+
192+
expect(getQQBotMediaPath()).toBe(path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot"));
193+
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
194+
});
195+
196+
it("expands tilde-prefixed OPENCLAW_HOME against the OS home", () => {
197+
// Use a unique subdirectory name so we can clean it up safely without
198+
// touching anything that exists under the real home.
199+
const sub = `qqbot-tilde-${process.pid}-${Date.now()}`;
200+
const expectedHome = path.join(realOsHome, sub);
201+
tempPaths.push(expectedHome);
202+
vi.stubEnv("OPENCLAW_HOME", `~/${sub}`);
203+
204+
expect(getQQBotMediaPath()).toBe(path.join(expectedHome, ".openclaw", "media", "qqbot"));
205+
206+
const mediaFile = path.join(expectedHome, ".openclaw", "media", "qqbot", "tilde.png");
207+
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
208+
fs.writeFileSync(mediaFile, "image", "utf8");
209+
210+
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
211+
});
212+
213+
it("falls back to OS home when OPENCLAW_HOME is unset (no regression)", () => {
214+
vi.stubEnv("OPENCLAW_HOME", "");
215+
216+
expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot"));
217+
});
218+
219+
it("treats sentinel strings 'undefined' and 'null' as unset", () => {
220+
for (const sentinel of ["undefined", "null"]) {
221+
vi.stubEnv("OPENCLAW_HOME", sentinel);
222+
expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot"));
223+
}
224+
});
225+
226+
it("keeps persisted QQ Bot data anchored on the OS home (compatibility)", () => {
227+
const fakeOpenclawHome = makeFakeOpenclawHome();
228+
vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome);
229+
230+
// Persisted state (sessions, known users, refs) must NOT migrate when an
231+
// operator adds OPENCLAW_HOME — otherwise existing deployments would lose
232+
// their session state. Only the media root follows OPENCLAW_HOME.
233+
expect(getQQBotDataPath()).toBe(path.join(realOsHome, ".openclaw", "qqbot"));
234+
});
235+
236+
it("rejects files that live under HOME tree when OPENCLAW_HOME is the active root", () => {
237+
const fakeOpenclawHome = makeFakeOpenclawHome();
238+
vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome);
239+
240+
// File under the HOME-side mirror — exactly the path that *worked* on
241+
// current main and *broke* the OPENCLAW_HOME setup. After the fix the
242+
// active media root is OPENCLAW_HOME, so a file under HOME is no longer
243+
// implicitly allowed unless it remaps via the existing workspace fallback.
244+
// Use a unique subdirectory so we never collide with real user media.
245+
const stale = `qqbot-stale-${process.pid}-${Date.now()}.png`;
246+
const homeOnlyFile = path.join(realOsHome, ".openclaw", "media", "qqbot", stale);
247+
tempPaths.push(homeOnlyFile);
248+
fs.mkdirSync(path.dirname(homeOnlyFile), { recursive: true });
249+
fs.writeFileSync(homeOnlyFile, "image", "utf8");
250+
251+
expect(resolveQQBotPayloadLocalFilePath(homeOnlyFile)).toBeNull();
252+
});
253+
254+
it("remaps workspace paths under either HOME or OPENCLAW_HOME to the OPENCLAW_HOME media root", () => {
255+
const fakeOpenclawHome = makeFakeOpenclawHome();
256+
vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome);
257+
258+
const baseName = `remap-${process.pid}-${Date.now()}`;
259+
260+
// Real file lives under the OPENCLAW_HOME media tree.
261+
const mediaFile = path.join(
262+
fakeOpenclawHome,
263+
".openclaw",
264+
"media",
265+
"qqbot",
266+
"downloads",
267+
baseName,
268+
"remap.png",
269+
);
270+
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
271+
fs.writeFileSync(mediaFile, "image", "utf8");
272+
273+
// Agent that only knows the HOME-relative workspace path should still
274+
// resolve to the real file thanks to the dual-tree workspace fallback.
275+
const homeWorkspaceDir = path.join(realOsHome, ".openclaw", "workspace", "qqbot");
276+
const homeWorkspacePath = path.join(homeWorkspaceDir, "downloads", baseName, "remap.png");
277+
// Track for cleanup; we only created the unique baseName subdir indirectly
278+
// through resolveQQBotLocalMediaPath, which does NOT actually create the
279+
// HOME-side path, so nothing to clean up there beyond the OPENCLAW_HOME tree.
280+
expect(resolveQQBotLocalMediaPath(homeWorkspacePath)).toBe(mediaFile);
281+
282+
// Same path but under OPENCLAW_HOME should also remap.
283+
const openclawWorkspacePath = path.join(
284+
fakeOpenclawHome,
285+
".openclaw",
286+
"workspace",
287+
"qqbot",
288+
"downloads",
289+
baseName,
290+
"remap.png",
291+
);
292+
expect(resolveQQBotLocalMediaPath(openclawWorkspacePath)).toBe(mediaFile);
293+
});
294+
});

extensions/qqbot/src/engine/utils/platform.ts

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import { formatErrorMessage } from "./format.js";
1414
import { debugLog, debugWarn } from "./log.js";
1515

1616
/**
17-
* Resolve the current user's home directory safely across platforms.
17+
* Resolve the current user's OS home directory safely across platforms.
1818
*
1919
* Priority:
2020
* 1. `os.homedir()`
2121
* 2. `$HOME` or `%USERPROFILE%`
2222
* 3. PlatformAdapter.getTempDir() as a last resort
23+
*
24+
* This is the *operating-system* home and intentionally ignores
25+
* `OPENCLAW_HOME`. Persistent QQ Bot data (sessions, known users, refs) is
26+
* keyed on this value to keep upgrades from hiding existing state when an
27+
* operator later sets `OPENCLAW_HOME`.
2328
*/
2429
export function getHomeDir(): string {
2530
try {
@@ -39,7 +44,46 @@ export function getHomeDir(): string {
3944
return getPlatformAdapter().getTempDir();
4045
}
4146

42-
/** Return a path under `~/.openclaw/qqbot` without creating it. */
47+
/**
48+
* Resolve the effective OpenClaw home directory.
49+
*
50+
* Mirrors the contract from core (`src/infra/home-dir.ts::resolveEffectiveHomeDir`)
51+
* so QQ Bot media roots live under the same tree the rest of OpenClaw treats as
52+
* `~`. The extension cannot import the core helper directly (it is a separate
53+
* package with `openclaw` as a peer dependency), so this re-implements the
54+
* minimal contract:
55+
*
56+
* 1. `OPENCLAW_HOME` when set (with `~` / `~/...` expanded against the OS home).
57+
* 2. Otherwise fall back to {@link getHomeDir} so existing single-home
58+
* deployments are unaffected.
59+
*
60+
* Empty / `"undefined"` / `"null"` strings are treated as unset to match how
61+
* core normalizes the variable.
62+
*/
63+
function resolveOpenClawHome(): string {
64+
const raw = process.env.OPENCLAW_HOME?.trim();
65+
if (!raw || raw === "undefined" || raw === "null") {
66+
return getHomeDir();
67+
}
68+
69+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
70+
const osHome = getHomeDir();
71+
if (raw === "~") {
72+
return osHome;
73+
}
74+
return path.join(osHome, raw.slice(2));
75+
}
76+
77+
return raw;
78+
}
79+
80+
/**
81+
* Return a path under `~/.openclaw/qqbot` without creating it.
82+
*
83+
* Anchored on the OS home (not `OPENCLAW_HOME`) so persisted QQ Bot data
84+
* (sessions, known users, ref index, credential backups) does not silently
85+
* disappear when an operator adds `OPENCLAW_HOME` after the fact.
86+
*/
4387
export function getQQBotDataPath(...subPaths: string[]): string {
4488
return path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths);
4589
}
@@ -54,16 +98,19 @@ export function getQQBotDataDir(...subPaths: string[]): string {
5498
}
5599

56100
/**
57-
* Return a path under `~/.openclaw/media/qqbot` without creating it.
101+
* Return a path under `<openclaw-home>/.openclaw/media/qqbot` without creating it.
58102
*
59-
* Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist so
60-
* downloaded images and audio can be accessed by framework media tooling.
103+
* Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist
104+
* so downloaded images and audio can be accessed by framework media tooling.
105+
* The base honors `OPENCLAW_HOME` (when set) so files written by agents into
106+
* the OpenClaw-managed media tree are reachable by this plugin even when
107+
* `HOME` and `OPENCLAW_HOME` differ (Docker, multi-user hosts). Fixes #83562.
61108
*/
62109
export function getQQBotMediaPath(...subPaths: string[]): string {
63-
return path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths);
110+
return path.join(resolveOpenClawHome(), ".openclaw", "media", "qqbot", ...subPaths);
64111
}
65112

66-
/** Return a path under `~/.openclaw/media/qqbot`, creating it on demand. */
113+
/** Return a path under `<openclaw-home>/.openclaw/media/qqbot`, creating it on demand. */
67114
export function getQQBotMediaDir(...subPaths: string[]): string {
68115
const dir = getQQBotMediaPath(...subPaths);
69116
if (!fs.existsSync(dir)) {
@@ -73,17 +120,18 @@ export function getQQBotMediaDir(...subPaths: string[]): string {
73120
}
74121

75122
/**
76-
* Return `~/.openclaw/media`, OpenClaw's shared media root.
123+
* Return `<openclaw-home>/.openclaw/media`, OpenClaw's shared media root.
77124
*
78125
* This mirrors the directory that core's `buildMediaLocalRoots` exposes as an
79126
* allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a
80127
* QQ Bot payload root lets the plugin trust framework-produced files that live
81128
* in sibling subdirectories such as `outbound/` (written by
82129
* `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping
83-
* the check anchored to a single, well-known directory.
130+
* the check anchored to a single, well-known directory. Like
131+
* {@link getQQBotMediaPath}, the base honors `OPENCLAW_HOME`.
84132
*/
85133
function getOpenClawMediaDir(): string {
86-
return path.join(getHomeDir(), ".openclaw", "media");
134+
return path.join(resolveOpenClawHome(), ".openclaw", "media");
87135
}
88136

89137
// ---- Basic platform information ----
@@ -203,12 +251,21 @@ export function resolveQQBotLocalMediaPath(p: string): string {
203251
return normalized;
204252
}
205253

206-
const homeDir = getHomeDir();
254+
const osHomeDir = getHomeDir();
255+
const openclawHomeDir = resolveOpenClawHome();
207256
const mediaRoot = getQQBotMediaPath();
208257
const dataRoot = getQQBotDataPath();
209-
const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot");
258+
// When OPENCLAW_HOME differs from HOME we have to consider workspace roots
259+
// under both trees: agents may be configured with `~`-relative paths (HOME)
260+
// or with the OpenClaw-managed home tree. Deduplicate when they match.
261+
const workspaceRoots = Array.from(
262+
new Set([
263+
path.join(osHomeDir, ".openclaw", "workspace", "qqbot"),
264+
path.join(openclawHomeDir, ".openclaw", "workspace", "qqbot"),
265+
]),
266+
);
210267
const candidateRoots = [
211-
{ from: workspaceRoot, to: mediaRoot },
268+
...workspaceRoots.map((from) => ({ from, to: mediaRoot })),
212269
{ from: dataRoot, to: mediaRoot },
213270
{ from: mediaRoot, to: dataRoot },
214271
];

0 commit comments

Comments
 (0)