@@ -4,6 +4,8 @@ import path from "node:path";
44import { afterEach , describe , expect , it , vi } from "vitest" ;
55import {
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+ } ) ;
0 commit comments