diff --git a/CHANGELOG.md b/CHANGELOG.md index 427bd37f9ce..ddbd8d17674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/bootstrap: repair completed workspaces that still have a stale `BOOTSTRAP.md` by detecting customized identity/profile files, deleting the stale bootstrap file, and recording setup completion. - Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions. - Codex harness/context-engine: redact context-engine assembly failures before logging, so fallback warnings do not serialize raw error objects. (#70809) Thanks @jalehman. - WhatsApp/onboarding: keep first-run setup entry loading off the Baileys runtime dependency path, so packaged QuickStart installs can show WhatsApp setup before runtime deps are staged. Fixes #70932. diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2579ab7e96f..d110f3dcc89 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -9,12 +9,14 @@ import { DEFAULT_HEARTBEAT_FILENAME, DEFAULT_IDENTITY_FILENAME, DEFAULT_MEMORY_FILENAME, + DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, filterBootstrapFilesForSession, isWorkspaceBootstrapPending, loadWorkspaceBootstrapFiles, + reconcileWorkspaceBootstrapCompletion, resolveWorkspaceBootstrapStatus, resolveDefaultAgentWorkspaceDir, type WorkspaceBootstrapFile, @@ -184,6 +186,80 @@ describe("ensureAgentWorkspace", () => { await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true); }); + it("keeps bootstrap status read-only when stale completion evidence exists", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-"); + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_IDENTITY_FILENAME, + content: "# IDENTITY.md\n\n- **Name:** Example\n", + }); + + await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending"); + await expect( + fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)), + ).resolves.toBeUndefined(); + expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined(); + }); + + it("repairs stale BOOTSTRAP.md when profile files show onboarding completed", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-"); + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_IDENTITY_FILENAME, + content: "# IDENTITY.md\n\n- **Name:** Example\n", + }); + + await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({ + repaired: true, + bootstrapExists: false, + }); + await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({ + code: "ENOENT", + }); + const state = await readWorkspaceState(tempDir); + expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete"); + await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false); + }); + + it("uses SOUL.md customization as stale bootstrap completion evidence", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-"); + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + await writeWorkspaceFile({ + dir: tempDir, + name: DEFAULT_SOUL_FILENAME, + content: "# SOUL.md\n\nUse a concise, practical voice.\n", + }); + + await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({ + repaired: true, + bootstrapExists: false, + }); + await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("does not treat git alone as stale bootstrap completion evidence", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-"); + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + await fs.mkdir(path.join(tempDir, ".git"), { recursive: true }); + await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + + await expect(reconcileWorkspaceBootstrapCompletion(tempDir)).resolves.toMatchObject({ + repaired: false, + bootstrapExists: true, + }); + await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending"); + await expect( + fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)), + ).resolves.toBeUndefined(); + expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined(); + }); + it("reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index d9a71293181..042f4ee72ab 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -38,6 +38,11 @@ export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME; const WORKSPACE_STATE_DIRNAME = ".openclaw"; const WORKSPACE_STATE_FILENAME = "workspace-state.json"; const WORKSPACE_STATE_VERSION = 1; +const WORKSPACE_ONBOARDING_PROFILE_FILENAMES = [ + DEFAULT_SOUL_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, +] as const; const workspaceTemplateCache = new Map>(); let gitAvailabilityPromise: Promise | null = null; @@ -205,6 +210,111 @@ async function fileExists(filePath: string): Promise { } } +async function fileContentDiffersFromTemplate( + filePath: string, + template: string, +): Promise { + try { + return (await fs.readFile(filePath, "utf-8")) !== template; + } catch (err) { + const anyErr = err as { code?: string }; + if (anyErr.code !== "ENOENT") { + throw err; + } + return false; + } +} + +async function hasWorkspaceUserContentEvidence( + dir: string, + opts?: { includeGit?: boolean }, +): Promise { + const indicators = [path.join(dir, "memory")]; + if (opts?.includeGit) { + indicators.push(path.join(dir, ".git")); + } + for (const indicator of indicators) { + try { + await fs.access(indicator); + return true; + } catch { + // continue + } + } + return await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME); +} + +async function workspaceProfileLooksConfigured(params: { + dir: string; + includeGitEvidence?: boolean; +}): Promise { + const profileFileDiffs = await Promise.all( + WORKSPACE_ONBOARDING_PROFILE_FILENAMES.map(async (fileName) => + fileContentDiffersFromTemplate(path.join(params.dir, fileName), await loadTemplate(fileName)), + ), + ); + return ( + profileFileDiffs.some(Boolean) || + (await hasWorkspaceUserContentEvidence(params.dir, { + includeGit: params.includeGitEvidence, + })) + ); +} + +async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise { + return await workspaceProfileLooksConfigured(params); +} + +type WorkspaceBootstrapCompletionReconcileResult = { + repaired: boolean; + bootstrapExists: boolean; + state: WorkspaceSetupState; +}; + +async function reconcileWorkspaceBootstrapCompletionState(params: { + dir: string; + bootstrapPath: string; + statePath: string; + state: WorkspaceSetupState; + bootstrapExists?: boolean; +}): Promise { + const bootstrapExists = params.bootstrapExists ?? (await fileExists(params.bootstrapPath)); + if ( + typeof params.state.setupCompletedAt === "string" && + params.state.setupCompletedAt.trim().length > 0 + ) { + return { repaired: false, bootstrapExists, state: params.state }; + } + + if (params.state.bootstrapSeededAt && !bootstrapExists) { + const completedState: WorkspaceSetupState = { + ...params.state, + setupCompletedAt: new Date().toISOString(), + }; + await writeWorkspaceSetupState(params.statePath, completedState); + return { repaired: true, bootstrapExists: false, state: completedState }; + } + + if ( + !bootstrapExists || + !(await workspaceHasBootstrapCompletionEvidence({ + dir: params.dir, + })) + ) { + return { repaired: false, bootstrapExists, state: params.state }; + } + + const now = new Date().toISOString(); + const repairedState: WorkspaceSetupState = { + ...params.state, + bootstrapSeededAt: params.state.bootstrapSeededAt ?? now, + setupCompletedAt: now, + }; + await fs.rm(params.bootstrapPath, { force: true }); + await writeWorkspaceSetupState(params.statePath, repairedState); + return { repaired: true, bootstrapExists: false, state: repairedState }; +} + function resolveWorkspaceStatePath(dir: string): string { return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME); } @@ -230,11 +340,15 @@ function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null { } } -async function readWorkspaceSetupState(statePath: string): Promise { +async function readWorkspaceSetupState( + statePath: string, + opts?: { persistLegacyMigration?: boolean }, +): Promise { try { const raw = await fs.readFile(statePath, "utf-8"); const parsed = parseWorkspaceSetupState(raw); if ( + opts?.persistLegacyMigration && parsed && raw.includes('"onboardingCompletedAt"') && !raw.includes('"setupCompletedAt"') && @@ -268,18 +382,40 @@ export async function resolveWorkspaceBootstrapStatus( dir: string, ): Promise<"pending" | "complete"> { const resolvedDir = resolveUserPath(dir); - const state = await readWorkspaceSetupStateForDir(resolvedDir); + const statePath = resolveWorkspaceStatePath(resolvedDir); + const state = await readWorkspaceSetupState(statePath); if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) { return "complete"; } - const bootstrapExists = await fileExists(path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME)); - return bootstrapExists ? "pending" : "complete"; + const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME); + const bootstrapExists = await fileExists(bootstrapPath); + if (!bootstrapExists) { + return "complete"; + } + return "pending"; } export async function isWorkspaceBootstrapPending(dir: string): Promise { return (await resolveWorkspaceBootstrapStatus(dir)) === "pending"; } +export async function reconcileWorkspaceBootstrapCompletion( + dir: string, +): Promise { + const resolvedDir = resolveUserPath(dir); + const statePath = resolveWorkspaceStatePath(resolvedDir); + const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME); + const state = await readWorkspaceSetupState(statePath, { + persistLegacyMigration: true, + }); + return await reconcileWorkspaceBootstrapCompletionState({ + dir: resolvedDir, + bootstrapPath, + statePath, + state, + }); +} + async function writeWorkspaceSetupState( statePath: string, state: WorkspaceSetupState, @@ -401,7 +537,9 @@ export async function ensureAgentWorkspace(params?: { await writeFileIfMissing(userPath, userTemplate); await writeFileIfMissing(heartbeatPath, heartbeatTemplate); - let state = await readWorkspaceSetupState(statePath); + let state = await readWorkspaceSetupState(statePath, { + persistLegacyMigration: true, + }); let stateDirty = false; const markState = (next: Partial) => { state = { ...state, ...next }; @@ -414,33 +552,31 @@ export async function ensureAgentWorkspace(params?: { markState({ bootstrapSeededAt: nowIso() }); } - if (!state.setupCompletedAt && state.bootstrapSeededAt && !bootstrapExists) { - markState({ setupCompletedAt: nowIso() }); + if (!state.setupCompletedAt) { + const repair = await reconcileWorkspaceBootstrapCompletionState({ + dir, + bootstrapPath, + statePath, + state, + bootstrapExists, + }); + if (repair.repaired) { + state = repair.state; + stateDirty = false; + bootstrapExists = repair.bootstrapExists; + } } if (!state.bootstrapSeededAt && !state.setupCompletedAt && !bootstrapExists) { // Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content // indicators exist, treat setup as complete and avoid recreating BOOTSTRAP for // already-configured workspaces. - const [identityContent, userContent] = await Promise.all([ - fs.readFile(identityPath, "utf-8"), - fs.readFile(userPath, "utf-8"), - ]); - const hasUserContent = await (async () => { - const indicators = [path.join(dir, "memory"), path.join(dir, ".git")]; - for (const indicator of indicators) { - try { - await fs.access(indicator); - return true; - } catch { - // continue - } - } - return await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME); - })(); - const legacySetupCompleted = - identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent; - if (legacySetupCompleted) { + if ( + await workspaceProfileLooksConfigured({ + dir, + includeGitEvidence: true, + }) + ) { markState({ setupCompletedAt: nowIso() }); } else { const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);