11import fs from "node:fs/promises" ;
22import os from "node:os" ;
33import path from "node:path" ;
4+ import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks" ;
45import {
56 resolvePinnedHostnameWithPolicy ,
67 resolveSsrFPolicyForUrl ,
78 type LookupFn ,
89} from "openclaw/plugin-sdk/ssrf-runtime" ;
9- import { describe , expect , it , vi } from "vitest" ;
10+ import { afterEach , describe , expect , it , vi } from "vitest" ;
1011import {
1112 fetchWorkspaceGallery ,
1213 installWorkspaceGalleryWidget ,
@@ -73,6 +74,10 @@ async function withTempStateDir<T>(run: (stateDir: string) => Promise<T>): Promi
7374 }
7475}
7576
77+ afterEach ( ( ) => {
78+ __setFsSafeTestHooksForTest ( undefined ) ;
79+ } ) ;
80+
7681describe ( "parseWorkspaceGalleryConfig" , ( ) => {
7782 it ( "accepts only explicit HTTPS origins" , ( ) => {
7883 expect (
@@ -281,7 +286,7 @@ describe("installWorkspaceGalleryWidget", () => {
281286 }
282287 } ) ;
283288
284- it ( "reserves the final directory exclusively before exposing the complete tree" , async ( ) => {
289+ it ( "keeps partial files out of the registry until the complete tree is present " , async ( ) => {
285290 await withTempStateDir ( async ( stateDir ) => {
286291 const store = new WorkspaceStore ( { stateDir } ) ;
287292 const files = Object . fromEntries (
@@ -296,8 +301,7 @@ describe("installWorkspaceGalleryWidget", () => {
296301 ) ;
297302 const widgetDir = resolveWidgetDir ( "weather" , stateDir ) ;
298303 let settled = false ;
299- let observedReservation = false ;
300- let observedPartial = false ;
304+ let observedUnregistered = false ;
301305
302306 const install = installWorkspaceGalleryWidget (
303307 "https://gallery.example/widgets/weather.json" ,
@@ -315,32 +319,94 @@ describe("installWorkspaceGalleryWidget", () => {
315319 if ( settled ) {
316320 break ;
317321 }
318- try {
322+ const registryEntry = store . read ( ) . widgetsRegistry . weather ;
323+ if ( registryEntry === undefined ) {
324+ observedUnregistered = true ;
325+ } else {
319326 const observed = await fs . readdir ( widgetDir , { recursive : true } ) ;
320- if ( observed . length === 0 ) {
321- observedReservation = true ;
322- await expect ( fs . mkdir ( widgetDir ) ) . rejects . toMatchObject ( { code : "EEXIST" } ) ;
323- } else if ( ! expectedFiles . every ( ( file ) => observed . includes ( file ) ) ) {
324- observedPartial = true ;
325- break ;
326- }
327- } catch ( error ) {
328- if ( ( error as NodeJS . ErrnoException ) . code !== "ENOENT" ) {
329- throw error ;
330- }
327+ expect ( expectedFiles . every ( ( file ) => observed . includes ( file ) ) ) . toBe ( true ) ;
331328 }
332329 await new Promise < void > ( ( resolve ) => {
333330 setImmediate ( resolve ) ;
334331 } ) ;
335332 }
336333 await install ;
337334
338- expect ( observedReservation ) . toBe ( true ) ;
339- expect ( observedPartial ) . toBe ( false ) ;
335+ expect ( observedUnregistered ) . toBe ( true ) ;
340336 await expect ( fs . readdir ( widgetDir ) ) . resolves . toEqual ( expect . arrayContaining ( expectedFiles ) ) ;
341337 } ) ;
342338 } ) ;
343339
340+ it ( "does not replace a target directory created during reservation" , async ( ) => {
341+ await withTempStateDir ( async ( stateDir ) => {
342+ const store = new WorkspaceStore ( { stateDir } ) ;
343+ const widgetDir = resolveWidgetDir ( "weather" , stateDir ) ;
344+ let competitorInode : number | bigint | undefined ;
345+ __setFsSafeTestHooksForTest ( {
346+ beforeOpen : async ( targetPath ) => {
347+ if (
348+ competitorInode !== undefined ||
349+ ! path . basename ( targetPath ) . startsWith ( ".openclaw-gallery-reservation-" )
350+ ) {
351+ return ;
352+ }
353+ await fs . mkdir ( widgetDir ) ;
354+ competitorInode = ( await fs . stat ( widgetDir , { bigint : true } ) ) . ino ;
355+ } ,
356+ } ) ;
357+ const fetchGuard = vi . fn < WorkspaceGalleryFetch > ( async ( options ) =>
358+ guardedResponse ( jsonResponse ( bundle ( ) ) , options . url ) ,
359+ ) ;
360+
361+ await installWorkspaceGalleryWidget ( "https://gallery.example/widgets/weather.json" , {
362+ allowedOrigins : [ "https://gallery.example" ] ,
363+ fetchGuard,
364+ stateDir,
365+ store,
366+ actor : "user" ,
367+ } ) ;
368+
369+ expect ( competitorInode ) . toBeDefined ( ) ;
370+ expect ( ( await fs . stat ( widgetDir , { bigint : true } ) ) . ino ) . toBe ( competitorInode ) ;
371+ expect ( store . read ( ) . widgetsRegistry . weather ?. status ) . toBe ( "pending" ) ;
372+ } ) ;
373+ } ) ;
374+
375+ it ( "never cleans up a competitor file that wins create-new" , async ( ) => {
376+ await withTempStateDir ( async ( stateDir ) => {
377+ const store = new WorkspaceStore ( { stateDir } ) ;
378+ const widgetDir = resolveWidgetDir ( "weather" , stateDir ) ;
379+ const competitorPath = path . join ( widgetDir , "index.html" ) ;
380+ let competitorCreated = false ;
381+ __setFsSafeTestHooksForTest ( {
382+ beforeOpen : async ( targetPath ) => {
383+ if ( competitorCreated || path . basename ( targetPath ) !== "index.html" ) {
384+ return ;
385+ }
386+ competitorCreated = true ;
387+ await fs . writeFile ( competitorPath , "competitor" , { flag : "wx" } ) ;
388+ } ,
389+ } ) ;
390+ const fetchGuard = vi . fn < WorkspaceGalleryFetch > ( async ( options ) =>
391+ guardedResponse ( jsonResponse ( bundle ( ) ) , options . url ) ,
392+ ) ;
393+
394+ await expect (
395+ installWorkspaceGalleryWidget ( "https://gallery.example/widgets/weather.json" , {
396+ allowedOrigins : [ "https://gallery.example" ] ,
397+ fetchGuard,
398+ stateDir,
399+ store,
400+ actor : "user" ,
401+ } ) ,
402+ ) . rejects . toThrow ( / e x i s t / i) ;
403+
404+ expect ( competitorCreated ) . toBe ( true ) ;
405+ await expect ( fs . readFile ( competitorPath , "utf8" ) ) . resolves . toBe ( "competitor" ) ;
406+ expect ( store . read ( ) . widgetsRegistry . weather ) . toBeUndefined ( ) ;
407+ } ) ;
408+ } ) ;
409+
344410 it ( "serializes gallery installation with scaffolding for the same widget name" , async ( ) => {
345411 await withTempStateDir ( async ( stateDir ) => {
346412 const store = new WorkspaceStore ( { stateDir } ) ;
@@ -398,14 +464,42 @@ describe("installWorkspaceGalleryWidget", () => {
398464 expect ( results . filter ( ( result ) => result . status === "fulfilled" ) ) . toHaveLength ( 1 ) ;
399465 expect ( results . filter ( ( result ) => result . status === "rejected" ) ) . toHaveLength ( 1 ) ;
400466 const files = ( await fs . readdir ( resolveWidgetDir ( "weather" , stateDir ) ) ) . toSorted ( ) ;
467+ const reservationFiles = files . filter ( ( file ) =>
468+ file . startsWith ( ".openclaw-gallery-reservation-" ) ,
469+ ) ;
401470 expect ( [
402471 [ "index.html" , "widget.json" ] ,
403472 [ "README.md" , "index.html" , "widget.json" ] ,
404- ] ) . toContainEqual ( files ) ;
473+ ] ) . toContainEqual ( files . filter ( ( file ) => ! file . startsWith ( ".openclaw-gallery-reservation-" ) ) ) ;
474+ expect ( reservationFiles ) . toHaveLength ( results [ 0 ] ?. status === "fulfilled" ? 1 : 0 ) ;
405475 } ) ;
406476 } ) ;
407477
408- it ( "removes staging directories after a nested file write fails" , async ( ) => {
478+ it ( "retains a successful reservation marker instead of deleting an ambiguous pathname" , async ( ) => {
479+ await withTempStateDir ( async ( stateDir ) => {
480+ const store = new WorkspaceStore ( { stateDir } ) ;
481+ const fetchGuard = vi . fn < WorkspaceGalleryFetch > ( async ( options ) =>
482+ guardedResponse ( jsonResponse ( bundle ( ) ) , options . url ) ,
483+ ) ;
484+
485+ await expect (
486+ installWorkspaceGalleryWidget ( "https://gallery.example/widgets/weather.json" , {
487+ allowedOrigins : [ "https://gallery.example" ] ,
488+ fetchGuard,
489+ stateDir,
490+ store,
491+ actor : "user" ,
492+ } ) ,
493+ ) . resolves . toMatchObject ( { registry : { status : "pending" } } ) ;
494+
495+ const widgetEntries = await fs . readdir ( resolveWidgetDir ( "weather" , stateDir ) ) ;
496+ expect (
497+ widgetEntries . filter ( ( entry ) => entry . startsWith ( ".openclaw-gallery-reservation-" ) ) ,
498+ ) . toHaveLength ( 1 ) ;
499+ } ) ;
500+ } ) ;
501+
502+ it ( "leaves an unregistered reservation after an ambiguous partial write failure" , async ( ) => {
409503 await withTempStateDir ( async ( stateDir ) => {
410504 const store = new WorkspaceStore ( { stateDir } ) ;
411505 const tooLongName = `${ "x" . repeat ( 256 ) } .js` ;
@@ -432,7 +526,12 @@ describe("installWorkspaceGalleryWidget", () => {
432526 } ) ,
433527 ) . rejects . toThrow ( ) ;
434528
435- await expect ( fs . readdir ( path . join ( stateDir , "workspaces" , "widgets" ) ) ) . resolves . toEqual ( [ ] ) ;
529+ const widgetEntries = await fs . readdir ( resolveWidgetDir ( "weather" , stateDir ) ) ;
530+ expect (
531+ widgetEntries . some ( ( entry ) => entry . startsWith ( ".openclaw-gallery-reservation-" ) ) ,
532+ ) . toBe ( true ) ;
533+ expect ( widgetEntries ) . toContain ( "index.html" ) ;
534+ expect ( widgetEntries ) . not . toContain ( "widget.json" ) ;
436535 expect ( store . read ( ) . widgetsRegistry . weather ) . toBeUndefined ( ) ;
437536 } ) ;
438537 } ) ;
0 commit comments