mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user