Skip to content

Commit 9d65bcf

Browse files
committed
fix(agents): keep BOOTSTRAP.md pending on preseeded managed workspaces
When OpenClaw runs in a managed / GitOps / operator-style deployment (for example Kubernetes with a PVC-backed workspace), a fresh workspace can be preseeded with custom SOUL.md, IDENTITY.md, USER.md, and a user-provided BOOTSTRAP.md before OpenClaw ever runs. The bootstrap completion reconciler treated those profile-file diffs against built-in templates as evidence that the human onboarding flow had completed and deleted the user-provided BOOTSTRAP.md before it could run, leaving SKILL_USAGE.md uninitialized and onboarding cron jobs uncreated. The fix splits stale-completion evidence into two kinds: * Real user content (memory/, MEMORY.md, populated SKILL.md under skills/) is always a legitimate signal that a previous onboarding flow ran but did not persist completion, so legacy / local stale BOOTSTRAP.md recovery keeps working. * SOUL / IDENTITY / USER diffs against built-in templates are only accepted as completion evidence when `bootstrapSeededAt` was already persisted to disk by a prior process lifecycle (captured before `ensureAgentWorkspace` mutates state in memory). A fresh preseeded workspace therefore keeps BOOTSTRAP.md, leaves setupCompletedAt unset, and still records bootstrapSeededAt so a future lifecycle can repair an orphan BOOTSTRAP.md the normal way. Closes #91931 [AI-assisted]
1 parent bb6e477 commit 9d65bcf

2 files changed

Lines changed: 231 additions & 8 deletions

File tree

src/agents/workspace.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.md$/, "")}\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

src/agents/workspace.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,40 @@ async function workspaceAttestedGeneratedFilesIntact(
395395
return true;
396396
}
397397

398-
async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise<boolean> {
399-
return await workspaceProfileLooksConfigured(params);
398+
type WorkspaceBootstrapCompletionEvidence = {
399+
/** Any source of stale-completion evidence is present. */
400+
any: boolean;
401+
/**
402+
* At least one of SOUL.md / IDENTITY.md / USER.md differs from the built-in
403+
* template. On a managed / GitOps / operator-style deployment (#91931) this
404+
* can come from a platform-provided template rather than a completed human
405+
* onboarding flow, so the reconciler treats it as completion evidence only
406+
* when a prior process lifecycle has already persisted `bootstrapSeededAt`.
407+
*/
408+
profileFilesDiffer: boolean;
409+
/**
410+
* User content exists in the workspace (`memory/`, `MEMORY.md`, or a
411+
* populated SKILL.md under `skills/`). This is always a legitimate signal
412+
* the workspace has real user activity, regardless of lifecycle stage.
413+
*/
414+
hasUserContent: boolean;
415+
};
416+
417+
async function workspaceBootstrapCompletionEvidence(params: {
418+
dir: string;
419+
}): Promise<WorkspaceBootstrapCompletionEvidence> {
420+
const profileFileDiffs = await Promise.all(
421+
WORKSPACE_ONBOARDING_PROFILE_FILENAMES.map(async (fileName) =>
422+
fileContentDiffersFromTemplate(path.join(params.dir, fileName), await loadTemplate(fileName)),
423+
),
424+
);
425+
const profileFilesDiffer = profileFileDiffs.some(Boolean);
426+
const hasUserContent = await hasWorkspaceUserContentEvidence(params.dir);
427+
return {
428+
any: profileFilesDiffer || hasUserContent,
429+
profileFilesDiffer,
430+
hasUserContent,
431+
};
400432
}
401433

402434
type WorkspaceBootstrapCompletionReconcileResult = {
@@ -411,8 +443,24 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
411443
statePath: string;
412444
state: WorkspaceSetupState;
413445
bootstrapExists?: boolean;
446+
/**
447+
* Whether `bootstrapSeededAt` was already persisted to disk by a prior
448+
* process lifecycle (snapshotted before any in-memory mutation by the
449+
* current `ensureAgentWorkspace` call). Defaults to whatever is currently
450+
* on `params.state.bootstrapSeededAt`.
451+
*
452+
* For managed / GitOps / operator-style deployments (#91931) a fresh
453+
* workspace can be preseeded with custom SOUL.md, IDENTITY.md, USER.md, and
454+
* a user-provided BOOTSTRAP.md before OpenClaw ever runs. In that lifecycle
455+
* the profile-file diffs come from platform templates, not from a completed
456+
* human onboarding flow, so we must not treat them as completion evidence
457+
* and must not delete the user-provided BOOTSTRAP.md.
458+
*/
459+
bootstrapSeededInPriorLifecycle?: boolean;
414460
}): Promise<WorkspaceBootstrapCompletionReconcileResult> {
415461
const bootstrapExists = params.bootstrapExists ?? (await pathExists(params.bootstrapPath));
462+
const bootstrapSeededInPriorLifecycle =
463+
params.bootstrapSeededInPriorLifecycle ?? Boolean(params.state.bootstrapSeededAt);
416464
if (
417465
typeof params.state.setupCompletedAt === "string" &&
418466
params.state.setupCompletedAt.trim().length > 0
@@ -429,12 +477,28 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
429477
return { repaired: true, bootstrapExists: false, state: completedState };
430478
}
431479

432-
if (
433-
!bootstrapExists ||
434-
!(await workspaceHasBootstrapCompletionEvidence({
435-
dir: params.dir,
436-
}))
437-
) {
480+
if (!bootstrapExists) {
481+
return { repaired: false, bootstrapExists, state: params.state };
482+
}
483+
484+
const evidence = await workspaceBootstrapCompletionEvidence({ dir: params.dir });
485+
if (!evidence.any) {
486+
return { repaired: false, bootstrapExists, state: params.state };
487+
}
488+
489+
// Real user content (`memory/`, `MEMORY.md`, populated `skills/*/SKILL.md`)
490+
// is always legitimate stale-completion evidence: a user-content workspace
491+
// with a leftover BOOTSTRAP.md and no recorded state means a previous
492+
// onboarding finished without persisting completion, so we still repair.
493+
//
494+
// Profile-file customization (SOUL / IDENTITY / USER differing from the
495+
// built-in templates) is more ambiguous: on a managed / GitOps /
496+
// operator-style deployment (#91931) those diffs can come from a
497+
// platform-provided template applied before OpenClaw ever runs. On a fresh
498+
// lifecycle (no prior `bootstrapSeededAt` persisted to disk) we therefore
499+
// require user-content evidence too, so the user-provided BOOTSTRAP.md is
500+
// kept and the onboarding flow can actually run.
501+
if (!evidence.hasUserContent && !bootstrapSeededInPriorLifecycle) {
438502
return { repaired: false, bootstrapExists, state: params.state };
439503
}
440504

@@ -944,6 +1008,11 @@ export async function ensureAgentWorkspace(params?: {
9441008
const nowIso = () => new Date().toISOString();
9451009

9461010
let bootstrapExists = await pathExists(bootstrapPath);
1011+
// Snapshot the persisted bootstrapSeededAt before any in-memory mutation so
1012+
// the completion reconciler can distinguish a fresh preseeded workspace
1013+
// (no prior lifecycle) from a workspace that genuinely lived through a
1014+
// previous run. See #91931.
1015+
const bootstrapSeededInPriorLifecycle = Boolean(state.bootstrapSeededAt);
9471016
if (!state.bootstrapSeededAt && bootstrapExists) {
9481017
markState({ bootstrapSeededAt: nowIso() });
9491018
}
@@ -955,6 +1024,7 @@ export async function ensureAgentWorkspace(params?: {
9551024
statePath,
9561025
state,
9571026
bootstrapExists,
1027+
bootstrapSeededInPriorLifecycle,
9581028
});
9591029
if (repair.repaired) {
9601030
state = repair.state;

0 commit comments

Comments
 (0)