fix: rebuild sandbox skill prompts from sandbox workspace (#77661)

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley
2026-05-05 01:13:21 -04:00
committed by GitHub
parent a52010be7d
commit 349ce0056d
5 changed files with 86 additions and 17 deletions

View File

@@ -71,6 +71,8 @@ Docs: https://docs.openclaw.ai
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
- Control UI/chat: suppress `HEARTBEAT_OK` acknowledgement history, streams, deltas, and final events before they enter the transcript view, so repeated heartbeat no-op turns do not stack noisy bubbles. Thanks @BunsDev.
- Agents/skills: require exact `<location>` skill paths for both single-skill and multi-skill prompt selection, so agents do not guess or hard-code skill file paths. (#74161) Thanks @lanzhi-lee.
- Agents/skills: rebuild sandboxed non-rw run skill prompts from the sandbox workspace copy, so `<available_skills>` no longer points at host-only `~/.openclaw/skills` paths. Fixes #50590. Thanks @kidroca and @sallyom.
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.
- Slack/mentions: record thread participation for successful visible threaded Slack sends, including message-tool and media delivery paths, so unmentioned replies in bot-participated threads can bypass mention gating as documented. Fixes #77648. Thanks @bek91.
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.
@@ -1413,7 +1415,6 @@ Docs: https://docs.openclaw.ai
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
- Feishu: recreate WebSocket clients with monitor-owned backoff only after SDK reconnect exhaustion, preserving heartbeat defaults and shutdown cleanup without treating recoverable SDK callback errors as terminal, so persistent connections recover without manual gateway restart. Fixes #52618; duplicate evidence #59753; related #55532, #68766, #72411, and #73739. Thanks @vincentkoc, @schumilin, @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
- Agents/skills: require exact `<location>` skill paths for both single-skill and multi-skill prompt selection, so agents do not guess or hard-code skill file paths. (#74161) Thanks @lanzhi-lee.
## 2026.4.27

View File

@@ -576,15 +576,17 @@ async function compactEmbeddedPiSessionDirectOnce(
let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null;
let checkpointSnapshotRetained = false;
try {
const skillsSnapshotForRun =
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
workspaceDir: effectiveWorkspace,
config: params.config,
agentId: effectiveSkillAgentId,
skillsSnapshot: params.skillsSnapshot,
skillsSnapshot: skillsSnapshotForRun,
});
restoreSkillEnv = params.skillsSnapshot
restoreSkillEnv = skillsSnapshotForRun
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
snapshot: skillsSnapshotForRun,
config: params.config,
})
: applySkillEnvOverrides({
@@ -592,7 +594,7 @@ async function compactEmbeddedPiSessionDirectOnce(
config: params.config,
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
skillsSnapshot: skillsSnapshotForRun,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -210,6 +211,59 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
}
});
it("rebuilds skill prompt inputs from the sandbox workspace for non-rw sandbox runs", async () => {
const sandboxWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
tempPaths.push(sandboxWorkspace);
hoisted.resolveSandboxContextMock.mockResolvedValue({
enabled: true,
workspaceAccess: "ro",
workspaceDir: sandboxWorkspace,
});
await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey,
tempPaths,
attemptOverrides: {
skillsSnapshot: {
prompt:
"<available_skills><skill><location>~/.openclaw/skills/smaug/SKILL.md</location></skill></available_skills>",
skills: [{ name: "smaug" }],
resolvedSkills: [
{
name: "smaug",
description: "Host copy",
disableModelInvocation: false,
filePath: "/Users/alice/.openclaw/skills/smaug/SKILL.md",
baseDir: "/Users/alice/.openclaw/skills/smaug",
source: "openclaw-workspace",
sourceInfo: {
path: "/Users/alice/.openclaw/skills/smaug/SKILL.md",
source: "openclaw-workspace",
scope: "project",
origin: "top-level",
baseDir: "/Users/alice/.openclaw/skills/smaug",
},
},
],
},
},
});
expect(hoisted.resolveEmbeddedRunSkillEntriesMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: sandboxWorkspace,
skillsSnapshot: undefined,
}),
);
expect(hoisted.resolveSkillsPromptForRunMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: sandboxWorkspace,
skillsSnapshot: undefined,
}),
);
});
it("keeps before_prompt_build prependContext out of system prompt on transcriptPrompt runs", async () => {
const runBeforePromptBuild = vi.fn(async () => ({ prependContext: "dynamic hook context" }));
hoisted.getGlobalHookRunnerMock.mockReturnValue({

View File

@@ -69,13 +69,13 @@ type AttemptSpawnWorkspaceHoisted = {
installContextEngineLoopHookMock: UnknownMock;
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
releaseWsSessionMock: UnknownMock;
resolveBootstrapFilesForRunMock: Mock<
(...args: unknown[]) => Promise<WorkspaceBootstrapFile[]>
>;
resolveBootstrapFilesForRunMock: Mock<(...args: unknown[]) => Promise<WorkspaceBootstrapFile[]>>;
resolveBootstrapContextForRunMock: Mock<() => Promise<BootstrapContext>>;
isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise<boolean>>;
resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">;
hasCompletedBootstrapTurnMock: Mock<() => Promise<boolean>>;
resolveEmbeddedRunSkillEntriesMock: UnknownMock;
resolveSkillsPromptForRunMock: UnknownMock;
supportsModelToolsMock: Mock<(model?: unknown) => boolean>;
getGlobalHookRunnerMock: Mock<() => unknown>;
initializeGlobalHookRunnerMock: UnknownMock;
@@ -155,6 +155,11 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
() => "always",
);
const hasCompletedBootstrapTurnMock = vi.fn<() => Promise<boolean>>(async () => false);
const resolveEmbeddedRunSkillEntriesMock = vi.fn(() => ({
shouldLoadSkillEntries: false,
skillEntries: undefined,
}));
const resolveSkillsPromptForRunMock = vi.fn(() => "");
const supportsModelToolsMock = vi.fn<(model?: unknown) => boolean>(() => true);
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const initializeGlobalHookRunnerMock = vi.fn();
@@ -202,6 +207,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
isWorkspaceBootstrapPendingMock,
resolveContextInjectionModeMock,
hasCompletedBootstrapTurnMock,
resolveEmbeddedRunSkillEntriesMock,
resolveSkillsPromptForRunMock,
supportsModelToolsMock,
getGlobalHookRunnerMock,
initializeGlobalHookRunnerMock,
@@ -306,14 +313,12 @@ vi.mock("../../bootstrap-files.js", async () => {
vi.mock("../../skills.js", () => ({
applySkillEnvOverrides: () => () => {},
applySkillEnvOverridesFromSnapshot: () => () => {},
resolveSkillsPromptForRun: () => "",
resolveSkillsPromptForRun: (...args: unknown[]) => hoisted.resolveSkillsPromptForRunMock(...args),
}));
vi.mock("../skills-runtime.js", () => ({
resolveEmbeddedRunSkillEntries: () => ({
shouldLoadSkillEntries: false,
skillEntries: undefined,
}),
resolveEmbeddedRunSkillEntries: (...args: unknown[]) =>
hoisted.resolveEmbeddedRunSkillEntriesMock(...args),
}));
vi.mock("../context-engine-maintenance.js", () => ({
@@ -839,6 +844,11 @@ export function resetEmbeddedAttemptHarness(
hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false);
hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always");
hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false);
hoisted.resolveEmbeddedRunSkillEntriesMock.mockReset().mockReturnValue({
shouldLoadSkillEntries: false,
skillEntries: undefined,
});
hoisted.resolveSkillsPromptForRunMock.mockReset().mockReturnValue("");
hoisted.supportsModelToolsMock.mockReset().mockReturnValue(true);
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);

View File

@@ -713,15 +713,17 @@ export async function runEmbeddedAttempt(
| ((outcome: "completed" | "aborted" | "error", err?: unknown) => void)
| undefined;
try {
const skillsSnapshotForRun =
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
workspaceDir: effectiveWorkspace,
config: params.config,
agentId: sessionAgentId,
skillsSnapshot: params.skillsSnapshot,
skillsSnapshot: skillsSnapshotForRun,
});
restoreSkillEnv = params.skillsSnapshot
restoreSkillEnv = skillsSnapshotForRun
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
snapshot: skillsSnapshotForRun,
config: params.config,
})
: applySkillEnvOverrides({
@@ -730,7 +732,7 @@ export async function runEmbeddedAttempt(
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
skillsSnapshot: skillsSnapshotForRun,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,