diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0b6aa4315..a0fdb59160e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -218,6 +218,7 @@ Docs: https://docs.openclaw.ai - Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. - Browser/SSRF: enforce the existing current-tab URL navigation policy before tab-scoped debug, export, and read routes (console, page errors, network requests, trace start/stop, response body, screenshot, snapshot, storage, etc.) collect from an already-selected tab, so blocked tabs return a policy error instead of being read first and redacted only at response time. (#75731) Thanks @eleqtrizit. - Security/Windows: route the `.cmd`/`.bat` process wrapper through the shared Windows install-root resolver instead of `process.env.ComSpec`, so workspace dotenv-blocked `SystemRoot`/`WINDIR` overrides and unsafe values like UNC paths or path-lists cannot redirect `cmd.exe` selection on Windows. (#77472) Thanks @drobison00. +- Agents/bootstrap: honor `BOOTSTRAP.md` content injected by `agent:bootstrap` hooks when deciding whether bootstrap is pending, so hook-provided required setup instructions are included in the system prompt. (#77501) Thanks @ificator. ## 2026.5.3-1 diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index c1353d01e66..281e1593373 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -279,12 +279,23 @@ export async function resolveBootstrapContextForRun(params: { contextFiles: EmbeddedContextFile[]; }> { const bootstrapFiles = await resolveBootstrapFilesForRun(params); + const contextFiles = buildBootstrapContextForFiles(bootstrapFiles, params); + return { bootstrapFiles, contextFiles }; +} + +export function buildBootstrapContextForFiles( + bootstrapFiles: WorkspaceBootstrapFile[], + params: { + config?: OpenClawConfig; + warn?: (message: string) => void; + }, +): EmbeddedContextFile[] { const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { maxChars: resolveBootstrapMaxChars(params.config), totalMaxChars: resolveBootstrapTotalMaxChars(params.config), warn: params.warn, }); - return { bootstrapFiles, contextFiles }; + return contextFiles; } export { isWorkspaceBootstrapPending }; diff --git a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts index d73b4983b89..22adc253425 100644 --- a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts +++ b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts @@ -1,5 +1,6 @@ import type { BootstrapMode } from "../../bootstrap-mode.js"; import { resolveBootstrapMode } from "../../bootstrap-mode.js"; +import { DEFAULT_BOOTSTRAP_FILENAME, type WorkspaceBootstrapFile } from "../../workspace.js"; export type AttemptBootstrapRoutingInput = { workspaceBootstrapPending: boolean; @@ -24,6 +25,7 @@ export type AttemptWorkspaceBootstrapRoutingInput = Omit< "workspaceBootstrapPending" > & { isWorkspaceBootstrapPending: (workspaceDir: string) => Promise; + bootstrapFiles?: readonly WorkspaceBootstrapFile[]; }; export function resolveBootstrapContextTargets(params: { @@ -58,14 +60,28 @@ function resolveAttemptBootstrapRouting( }; } +export function hasBootstrapFileContent(files?: readonly WorkspaceBootstrapFile[]): boolean { + return ( + files?.some( + (file) => + file.name === DEFAULT_BOOTSTRAP_FILENAME && + !file.missing && + typeof file.content === "string" && + file.content.trim().length > 0, + ) ?? false + ); +} + export async function resolveAttemptWorkspaceBootstrapRouting( params: AttemptWorkspaceBootstrapRoutingInput, ): Promise { const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending( params.resolvedWorkspace, ); + const hasHookBootstrapContent = hasBootstrapFileContent(params.bootstrapFiles); return resolveAttemptBootstrapRouting({ ...params, - workspaceBootstrapPending, + workspaceBootstrapPending: workspaceBootstrapPending || hasHookBootstrapContent, + hasBootstrapFileAccess: params.hasBootstrapFileAccess || hasHookBootstrapContent, }); } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts index b5ddf161238..4ebb5be5fc6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + hasBootstrapFileContent, resolveBootstrapContextTargets, resolveAttemptWorkspaceBootstrapRouting, } from "./attempt-bootstrap-routing.js"; @@ -46,6 +47,67 @@ describe("runEmbeddedAttempt bootstrap routing", () => { expect(routing.includeBootstrapInRuntimeContext).toBe(false); }); + it("treats hook-provided BOOTSTRAP.md content as pending bootstrap context", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: true, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("uses hook-provided BOOTSTRAP.md content even when normal file reads are unavailable", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: false, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("does not treat empty hook-provided BOOTSTRAP.md as pending bootstrap context", () => { + expect( + hasBootstrapFileContent([ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: " ", + missing: false, + }, + ]), + ).toBe(false); + }); + it("keeps BOOTSTRAP.md in Project Context for full bootstrap turns", () => { expect(resolveBootstrapContextTargets({ bootstrapMode: "full" })).toEqual({ includeBootstrapInSystemContext: true, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 3ce99a1e8bb..93aecfdc561 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -316,6 +316,54 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(systemPrompt).toContain("Ask who I am."); }); + it("includes hook-adjusted bootstrap files preloaded before routing", async () => { + const workspaceDir = "/tmp/openclaw-hook-workspace"; + hoisted.resolveBootstrapFilesForRunMock.mockResolvedValueOnce([ + { + name: "BOOTSTRAP.md", + path: `${workspaceDir}/BOOTSTRAP.md`, + content: "Ask who I am before continuing.", + missing: false, + }, + ]); + + await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + config: { + agents: { + defaults: { + systemPromptOverride: "Custom override prompt.", + }, + }, + } as OpenClawConfig, + prompt: "visible ask", + transcriptPrompt: "visible ask", + trigger: "user", + workspaceDir, + }, + sessionPrompt: async (session) => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(hoisted.resolveBootstrapFilesForRunMock).toHaveBeenCalledOnce(); + expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled(); + const systemPrompt = + hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ?? + ""; + + expect(systemPrompt).toContain("## Bootstrap Pending"); + expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context"); + expect(systemPrompt).toContain(`## ${workspaceDir}/BOOTSTRAP.md`); + expect(systemPrompt).toContain("Ask who I am before continuing."); + }); + it("adds explicit reply context to the current model input without exposing generic runtime context", async () => { let seenPrompt: string | undefined; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 8b05abcecf0..ab5605680cc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -69,6 +69,9 @@ type AttemptSpawnWorkspaceHoisted = { installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; + resolveBootstrapFilesForRunMock: Mock< + (...args: unknown[]) => Promise + >; resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; @@ -139,6 +142,12 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { bootstrapFiles: [], contextFiles: [], })); + const resolveBootstrapFilesForRunMock = vi.fn< + (...args: unknown[]) => Promise + >(async () => { + const context = await resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); const isWorkspaceBootstrapPendingMock = vi.fn<(workspaceDir: string) => Promise>( async () => false, ); @@ -188,6 +197,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { installContextEngineLoopHookMock, flushPendingToolResultsAfterIdleMock, releaseWsSessionMock, + resolveBootstrapFilesForRunMock, resolveBootstrapContextForRunMock, isWorkspaceBootstrapPendingMock, resolveContextInjectionModeMock, @@ -286,6 +296,7 @@ vi.mock("../../bootstrap-files.js", async () => { ...actual, makeBootstrapWarn: () => () => {}, isWorkspaceBootstrapPending: hoisted.isWorkspaceBootstrapPendingMock, + resolveBootstrapFilesForRun: hoisted.resolveBootstrapFilesForRunMock, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock, hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock, @@ -821,6 +832,10 @@ export function resetEmbeddedAttemptHarness( bootstrapFiles: [], contextFiles: [], }); + hoisted.resolveBootstrapFilesForRunMock.mockReset().mockImplementation(async () => { + const context = await hoisted.resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false); hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always"); hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 374a4d50710..9ae369f227d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -62,10 +62,11 @@ import { } from "../../bootstrap-budget.js"; import { FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + buildBootstrapContextForFiles, hasCompletedBootstrapTurn, isWorkspaceBootstrapPending, makeBootstrapWarn, - resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, resolveContextInjectionMode, } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -945,8 +946,26 @@ export async function runEmbeddedAttempt( emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot()); const toolsEnabled = supportsModelTools(params.model); const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read"); + const bootstrapWarn = makeBootstrapWarn({ + sessionLabel, + workspaceDir: resolvedWorkspace, + warn: (message) => log.warn(message), + }); + const preloadedBootstrapFiles = + isRawModelRun || contextInjectionMode === "never" + ? undefined + : await resolveBootstrapFilesForRun({ + workspaceDir: resolvedWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + }); const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({ isWorkspaceBootstrapPending, + bootstrapFiles: preloadedBootstrapFiles, bootstrapContextRunKind: params.bootstrapContextRunKind, trigger: params.trigger, sessionKey: params.sessionKey, @@ -970,20 +989,26 @@ export async function runEmbeddedAttempt( bootstrapMode, sessionFile: params.sessionFile, hasCompletedBootstrapTurn, - resolveBootstrapContextForRun: async () => - await resolveBootstrapContextForRun({ - workspaceDir: resolvedWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ - sessionLabel, + resolveBootstrapContextForRun: async () => { + const bootstrapFiles = + preloadedBootstrapFiles ?? + (await resolveBootstrapFilesForRun({ workspaceDir: resolvedWorkspace, - warn: (message) => log.warn(message), + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + })); + return { + bootstrapFiles, + contextFiles: buildBootstrapContextForFiles(bootstrapFiles, { + config: params.config, + warn: bootstrapWarn, }), - contextMode: params.bootstrapContextMode, - runKind: params.bootstrapContextRunKind, - }), + }; + }, }); prepStages.mark("bootstrap-context"); const remappedContextFiles = remapInjectedContextFilesToWorkspace({