From 349ce0056d6326f8f2602d39cda6eb0d0f60cdff Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Tue, 5 May 2026 01:13:21 -0400 Subject: [PATCH] fix: rebuild sandbox skill prompts from sandbox workspace (#77661) Signed-off-by: sallyom --- CHANGELOG.md | 3 +- src/agents/pi-embedded-runner/compact.ts | 10 ++-- ...mpt.spawn-workspace.context-engine.test.ts | 54 +++++++++++++++++++ .../attempt.spawn-workspace.test-support.ts | 26 ++++++--- src/agents/pi-embedded-runner/run/attempt.ts | 10 ++-- 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bf13daf6f..4aa2944a0b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `` 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-` 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 `` 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 diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 9917ddc9361..d36f9678057 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, 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 a94ece56bfc..08b4267c194 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 @@ -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: + "~/.openclaw/skills/smaug/SKILL.md", + 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({ 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 ab5605680cc..f9dc87c0e15 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,13 +69,13 @@ type AttemptSpawnWorkspaceHoisted = { installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; - resolveBootstrapFilesForRunMock: Mock< - (...args: unknown[]) => Promise - >; + resolveBootstrapFilesForRunMock: Mock<(...args: unknown[]) => Promise>; resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; hasCompletedBootstrapTurnMock: Mock<() => Promise>; + 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>(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); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d1187f131e6..49835399da1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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,