@@ -652,6 +652,159 @@ describe("ensureAgentWorkspace", () => {
652652 expect ( ( await readWorkspaceState ( tempDir ) ) . setupCompletedAt ) . toBeUndefined ( ) ;
653653 } ) ;
654654
655+ // Regression: managed / GitOps / operator-style deployments can preseed a
656+ // workspace with custom SOUL.md, IDENTITY.md, USER.md, and a user-provided
657+ // BOOTSTRAP.md before OpenClaw ever runs. Profile-file diffs alone must not
658+ // count as completion evidence on a fresh lifecycle, and the user-provided
659+ // BOOTSTRAP.md must not be deleted before the first onboarding flow runs.
660+ // See https://github.com/openclaw/openclaw/issues/91931.
661+ describe ( "preseeded managed workspace keeps bootstrap pending" , ( ) => {
662+ const preseededProfileFiles = [
663+ DEFAULT_SOUL_FILENAME ,
664+ DEFAULT_IDENTITY_FILENAME ,
665+ DEFAULT_USER_FILENAME ,
666+ ] as const ;
667+
668+ const profileSeedContent = ( name : string ) =>
669+ `# Custom platform-provided ${ name . replace ( / \. m d $ / , "" ) } \n\nManaged template, not user-completed onboarding.\n` ;
670+
671+ async function seedManagedPreseededWorkspace ( dir : string , fileNames : readonly string [ ] ) {
672+ await fs . writeFile (
673+ path . join ( dir , DEFAULT_BOOTSTRAP_FILENAME ) ,
674+ "# User onboarding flow\n\n1. Greet user.\n2. Delete this file.\n" ,
675+ "utf-8" ,
676+ ) ;
677+ for ( const name of fileNames ) {
678+ await fs . writeFile ( path . join ( dir , name ) , profileSeedContent ( name ) , "utf-8" ) ;
679+ }
680+ }
681+
682+ it . each ( [
683+ [ "SOUL.md only" , [ DEFAULT_SOUL_FILENAME ] ] ,
684+ [ "IDENTITY.md only" , [ DEFAULT_IDENTITY_FILENAME ] ] ,
685+ [ "USER.md only" , [ DEFAULT_USER_FILENAME ] ] ,
686+ [ "all profile files" , [ ...preseededProfileFiles ] ] ,
687+ ] ) (
688+ "keeps BOOTSTRAP.md and setupCompletedAt unset via reconcileWorkspaceBootstrapCompletion (%s)" ,
689+ async ( _label , fileNames ) => {
690+ const tempDir = await makeTempWorkspace ( "openclaw-workspace-" ) ;
691+ await seedManagedPreseededWorkspace ( tempDir , fileNames ) ;
692+
693+ const result = await reconcileWorkspaceBootstrapCompletion ( tempDir ) ;
694+
695+ expect ( result . repaired ) . toBe ( false ) ;
696+ expect ( result . bootstrapExists ) . toBe ( true ) ;
697+ expect ( result . state . setupCompletedAt ) . toBeUndefined ( ) ;
698+ await expect (
699+ fs . access ( path . join ( tempDir , DEFAULT_BOOTSTRAP_FILENAME ) ) ,
700+ ) . resolves . toBeUndefined ( ) ;
701+ await expect ( resolveWorkspaceBootstrapStatus ( tempDir ) ) . resolves . toBe ( "pending" ) ;
702+ await expect ( isWorkspaceBootstrapPending ( tempDir ) ) . resolves . toBe ( true ) ;
703+ } ,
704+ ) ;
705+
706+ it ( "keeps BOOTSTRAP.md when ensureAgentWorkspace runs on a preseeded workspace (matches K8s pod start)" , async ( ) => {
707+ const tempDir = await makeTempWorkspace ( "openclaw-workspace-" ) ;
708+ await seedManagedPreseededWorkspace ( tempDir , preseededProfileFiles ) ;
709+
710+ await ensureAgentWorkspace ( { dir : tempDir , ensureBootstrapFiles : true } ) ;
711+
712+ await expect (
713+ fs . access ( path . join ( tempDir , DEFAULT_BOOTSTRAP_FILENAME ) ) ,
714+ ) . resolves . toBeUndefined ( ) ;
715+ const state = await readWorkspaceState ( tempDir ) ;
716+ expect ( state . setupCompletedAt ) . toBeUndefined ( ) ;
717+ // bootstrapSeededAt is recorded so a future lifecycle can later treat
718+ // profile-file customization as legitimate stale-completion evidence.
719+ expect ( state . bootstrapSeededAt ) . toMatch ( / \d { 4 } - \d { 2 } - \d { 2 } T / ) ;
720+ await expect ( resolveWorkspaceBootstrapStatus ( tempDir ) ) . resolves . toBe ( "pending" ) ;
721+ await expect ( isWorkspaceBootstrapPending ( tempDir ) ) . resolves . toBe ( true ) ;
722+ } ) ;
723+
724+ it ( "still repairs stale BOOTSTRAP.md once a prior lifecycle has persisted bootstrapSeededAt" , async ( ) => {
725+ const tempDir = await makeTempWorkspace ( "openclaw-workspace-" ) ;
726+ await seedManagedPreseededWorkspace ( tempDir , preseededProfileFiles ) ;
727+
728+ // First lifecycle: ensureAgentWorkspace records bootstrapSeededAt but
729+ // keeps BOOTSTRAP.md pending because profile diffs alone are not
730+ // completion evidence on a fresh lifecycle.
731+ await ensureAgentWorkspace ( { dir : tempDir , ensureBootstrapFiles : true } ) ;
732+ const firstState = await readWorkspaceState ( tempDir ) ;
733+ expect ( firstState . bootstrapSeededAt ) . toMatch ( / \d { 4 } - \d { 2 } - \d { 2 } T / ) ;
734+ expect ( firstState . setupCompletedAt ) . toBeUndefined ( ) ;
735+ await expect (
736+ fs . access ( path . join ( tempDir , DEFAULT_BOOTSTRAP_FILENAME ) ) ,
737+ ) . resolves . toBeUndefined ( ) ;
738+
739+ // Second lifecycle: a prior bootstrapSeededAt is on disk, so the
740+ // reconciler may now treat profile-file diffs as legitimate stale
741+ // completion evidence and clean up the orphaned BOOTSTRAP.md.
742+ const result = await reconcileWorkspaceBootstrapCompletion ( tempDir ) ;
743+ expect ( result . repaired ) . toBe ( true ) ;
744+ expect ( result . bootstrapExists ) . toBe ( false ) ;
745+ await expectPathMissing ( path . join ( tempDir , DEFAULT_BOOTSTRAP_FILENAME ) ) ;
746+ const finalState = await readWorkspaceState ( tempDir ) ;
747+ expect ( finalState . setupCompletedAt ) . toMatch ( / \d { 4 } - \d { 2 } - \d { 2 } T / ) ;
748+ expect ( finalState . bootstrapSeededAt ) . toBe ( firstState . bootstrapSeededAt ) ;
749+ } ) ;
750+
751+ it . each ( [
752+ [
753+ "memory/ directory" ,
754+ async ( dir : string ) => {
755+ await fs . mkdir ( path . join ( dir , "memory" ) , { recursive : true } ) ;
756+ await fs . writeFile (
757+ path . join ( dir , "memory" , "2026-05-01.md" ) ,
758+ "# Daily log\nNotes from prior run.\n" ,
759+ "utf-8" ,
760+ ) ;
761+ } ,
762+ ] ,
763+ [
764+ "MEMORY.md" ,
765+ async ( dir : string ) => {
766+ await fs . writeFile (
767+ path . join ( dir , DEFAULT_MEMORY_FILENAME ) ,
768+ "# Long-term memory\nImportant stuff.\n" ,
769+ "utf-8" ,
770+ ) ;
771+ } ,
772+ ] ,
773+ [
774+ "populated skills/local-skill/SKILL.md" ,
775+ async ( dir : string ) => {
776+ await fs . mkdir ( path . join ( dir , "skills" , "local-skill" ) , { recursive : true } ) ;
777+ await fs . writeFile (
778+ path . join ( dir , "skills" , "local-skill" , "SKILL.md" ) ,
779+ "---\nname: local-skill\n---\n" ,
780+ "utf-8" ,
781+ ) ;
782+ } ,
783+ ] ,
784+ ] ) (
785+ "still repairs stale BOOTSTRAP.md when fresh-lifecycle workspace has real user content (%s)" ,
786+ async ( _label , seedUserContent ) => {
787+ // Legacy/local stale-bootstrap recovery must keep working even when
788+ // there is no prior `bootstrapSeededAt` on disk: real user-content
789+ // evidence (memory/, MEMORY.md, populated SKILL.md under skills/) is
790+ // always a legitimate signal that a previous onboarding flow ran but
791+ // did not persist completion, so the orphan BOOTSTRAP.md should be
792+ // cleared and `setupCompletedAt` recorded.
793+ const tempDir = await makeTempWorkspace ( "openclaw-workspace-" ) ;
794+ await seedManagedPreseededWorkspace ( tempDir , preseededProfileFiles ) ;
795+ await seedUserContent ( tempDir ) ;
796+
797+ const result = await reconcileWorkspaceBootstrapCompletion ( tempDir ) ;
798+
799+ expect ( result . repaired ) . toBe ( true ) ;
800+ expect ( result . bootstrapExists ) . toBe ( false ) ;
801+ await expectPathMissing ( path . join ( tempDir , DEFAULT_BOOTSTRAP_FILENAME ) ) ;
802+ const state = await readWorkspaceState ( tempDir ) ;
803+ expect ( state . setupCompletedAt ) . toMatch ( / \d { 4 } - \d { 2 } - \d { 2 } T / ) ;
804+ } ,
805+ ) ;
806+ } ) ;
807+
655808 it ( "reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded" , async ( ) => {
656809 const tempDir = await makeTempWorkspace ( "openclaw-workspace-" ) ;
657810
0 commit comments