From b4f14d6f7aa0850e1c06bb01d16f320073500d2d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Feb 2026 15:42:43 -0500 Subject: [PATCH] Gateway: hide BOOTSTRAP in agent files after onboarding completes (#17491) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f95f6dd052daf618bac6ed16bb4a8112a376d47d Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/agents/workspace.ts | 12 +++ .../server-methods/agents-mutate.test.ts | 75 +++++++++++++++++++ src/gateway/server-methods/agents.ts | 19 ++++- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e32292f08..ee1e1eeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. - Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez. diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index bf5f33992..9e1c081c7 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -184,6 +184,18 @@ async function readWorkspaceOnboardingState(statePath: string): Promise { + const statePath = resolveWorkspaceStatePath(resolveUserPath(dir)); + return await readWorkspaceOnboardingState(statePath); +} + +export async function isWorkspaceOnboardingCompleted(dir: string): Promise { + const state = await readWorkspaceOnboardingStateForDir(dir); + return ( + typeof state.onboardingCompletedAt === "string" && state.onboardingCompletedAt.trim().length > 0 + ); +} + async function writeWorkspaceOnboardingState( statePath: string, state: WorkspaceOnboardingState, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index bd5cc5cc3..eb13cc1da 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ fsAccess: vi.fn(async () => {}), fsMkdir: vi.fn(async () => undefined), fsAppendFile: vi.fn(async () => {}), + fsReadFile: vi.fn(async () => ""), + fsStat: vi.fn(async () => null), })); vi.mock("../../config/config.js", () => ({ @@ -81,6 +83,8 @@ vi.mock("node:fs/promises", async () => { access: mocks.fsAccess, mkdir: mocks.fsMkdir, appendFile: mocks.fsAppendFile, + readFile: mocks.fsReadFile, + stat: mocks.fsStat, }; return { ...patched, default: patched }; }); @@ -109,6 +113,27 @@ function makeCall(method: keyof typeof agentsHandlers, params: Record { + mocks.fsReadFile.mockImplementation(async () => { + throw createEnoentError(); + }); + mocks.fsStat.mockImplementation(async () => { + throw createEnoentError(); + }); +}); + /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ @@ -371,3 +396,53 @@ describe("agents.delete", () => { ); }); }); + +describe("agents.files.list", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfigReturn = {}; + }); + + it("includes BOOTSTRAP.md when onboarding has not completed", async () => { + const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); + await promise; + + const [, result] = respond.mock.calls[0] ?? []; + const files = (result as { files: Array<{ name: string }> }).files; + expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); + }); + + it("hides BOOTSTRAP.md when workspace onboarding is complete", async () => { + mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => { + if (String(filePath).endsWith("workspace-state.json")) { + return JSON.stringify({ + onboardingCompletedAt: "2026-02-15T14:00:00.000Z", + }); + } + throw createEnoentError(); + }); + + const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); + await promise; + + const [, result] = respond.mock.calls[0] ?? []; + const files = (result as { files: Array<{ name: string }> }).files; + expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(false); + }); + + it("falls back to showing BOOTSTRAP.md when workspace state cannot be read", async () => { + mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => { + if (String(filePath).endsWith("workspace-state.json")) { + throw createErrnoError("EACCES"); + } + throw createEnoentError(); + }); + + const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); + await promise; + + const [, result] = respond.mock.calls[0] ?? []; + const files = (result as { files: Array<{ name: string }> }).files; + expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); + }); +}); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index eb4262e43..2fbb07fd9 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -17,6 +17,7 @@ import { DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, + isWorkspaceOnboardingCompleted, } from "../../agents/workspace.js"; import { movePathToTrash } from "../../browser/trash.js"; import { @@ -52,6 +53,9 @@ const BOOTSTRAP_FILE_NAMES = [ DEFAULT_HEARTBEAT_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, ] as const; +const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter( + (name) => name !== DEFAULT_BOOTSTRAP_FILENAME, +); const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; @@ -108,7 +112,7 @@ async function statFile(filePath: string): Promise { } } -async function listAgentFiles(workspaceDir: string) { +async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) { const files: Array<{ name: string; path: string; @@ -117,7 +121,10 @@ async function listAgentFiles(workspaceDir: string) { updatedAtMs?: number; }> = []; - for (const name of BOOTSTRAP_FILE_NAMES) { + const bootstrapFileNames = options?.hideBootstrap + ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING + : BOOTSTRAP_FILE_NAMES; + for (const name of bootstrapFileNames) { const filePath = path.join(workspaceDir, name); const meta = await statFile(filePath); if (meta) { @@ -417,7 +424,13 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const files = await listAgentFiles(workspaceDir); + let hideBootstrap = false; + try { + hideBootstrap = await isWorkspaceOnboardingCompleted(workspaceDir); + } catch { + // Fall back to showing BOOTSTRAP if workspace state cannot be read. + } + const files = await listAgentFiles(workspaceDir, { hideBootstrap }); respond(true, { agentId, workspace: workspaceDir, files }, undefined); }, "agents.files.get": async ({ params, respond }) => {