fix(agents): honor hook bootstrap content (#77501)

* Problem: `agent:bootstrap` hooks can inject `BOOTSTRAP.md` content, but embedded-runner bootstrap routing decided whether bootstrap was pending before hook-adjusted files were considered.
* Fix: preload hook-adjusted bootstrap files before routing, treat non-empty hook-provided `BOOTSTRAP.md` as pending and accessible bootstrap content, and reuse the preloaded files when building Project Context.
* Tests: added routing + context-engine regression coverage for hook-injected bootstrap content.

Co-authored-by: ificator <8387253+ificator@users.noreply.github.com>
Co-authored-by: galiniliev <galini@microsoft.com>
This commit is contained in:
Brad
2026-05-04 13:48:40 -07:00
committed by GitHub
parent 7b86481c94
commit be8b4dc845
7 changed files with 193 additions and 15 deletions

View File

@@ -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

View File

@@ -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 };

View File

@@ -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<boolean>;
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<AttemptBootstrapRouting> {
const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending(
params.resolvedWorkspace,
);
const hasHookBootstrapContent = hasBootstrapFileContent(params.bootstrapFiles);
return resolveAttemptBootstrapRouting({
...params,
workspaceBootstrapPending,
workspaceBootstrapPending: workspaceBootstrapPending || hasHookBootstrapContent,
hasBootstrapFileAccess: params.hasBootstrapFileAccess || hasHookBootstrapContent,
});
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -69,6 +69,9 @@ type AttemptSpawnWorkspaceHoisted = {
installContextEngineLoopHookMock: UnknownMock;
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
releaseWsSessionMock: UnknownMock;
resolveBootstrapFilesForRunMock: Mock<
(...args: unknown[]) => Promise<WorkspaceBootstrapFile[]>
>;
resolveBootstrapContextForRunMock: Mock<() => Promise<BootstrapContext>>;
isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise<boolean>>;
resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">;
@@ -139,6 +142,12 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
bootstrapFiles: [],
contextFiles: [],
}));
const resolveBootstrapFilesForRunMock = vi.fn<
(...args: unknown[]) => Promise<WorkspaceBootstrapFile[]>
>(async () => {
const context = await resolveBootstrapContextForRunMock();
return context.bootstrapFiles;
});
const isWorkspaceBootstrapPendingMock = vi.fn<(workspaceDir: string) => Promise<boolean>>(
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);

View File

@@ -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({