Skip to content

Commit ba8a42f

Browse files
committed
fix(sandbox): render cli skill prompts from materialized paths
1 parent e3a6da0 commit ba8a42f

2 files changed

Lines changed: 185 additions & 7 deletions

File tree

src/agents/cli-runner/prepare.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { hashCliSessionText } from "../cli-session.js";
2020
import { resetContextWindowCacheForTest } from "../context.js";
2121
import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js";
2222
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
23+
import type { SandboxWorkspaceInfo } from "../sandbox/types.js";
2324
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js";
2425
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
2526
import {
@@ -29,12 +30,19 @@ import {
2930
} from "./prepare.js";
3031

3132
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({})));
33+
const ensureSandboxWorkspaceForSessionMock = vi.hoisted(() =>
34+
vi.fn<() => Promise<SandboxWorkspaceInfo | null>>(async () => null),
35+
);
3236
let sessionFileEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
3337

3438
vi.mock("../../config/config.js", () => ({
3539
getRuntimeConfig: getRuntimeConfigMock,
3640
}));
3741

42+
vi.mock("../sandbox.js", () => ({
43+
ensureSandboxWorkspaceForSession: ensureSandboxWorkspaceForSessionMock,
44+
}));
45+
3846
vi.mock("../../plugins/hook-runner-global.js", () => ({
3947
getGlobalHookRunner: vi.fn(() => null),
4048
}));
@@ -254,6 +262,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
254262
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(undefined);
255263
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined);
256264
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined);
265+
ensureSandboxWorkspaceForSessionMock.mockReset();
266+
ensureSandboxWorkspaceForSessionMock.mockResolvedValue(null);
257267
});
258268

259269
afterEach(() => {
@@ -263,6 +273,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
263273
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReset();
264274
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
265275
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
276+
ensureSandboxWorkspaceForSessionMock.mockReset();
266277
resetContextWindowCacheForTest();
267278
clearMemoryPluginState();
268279
vi.unstubAllEnvs();
@@ -1745,6 +1756,95 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
17451756
}
17461757
});
17471758

1759+
it("renders CLI skills from sandbox-readable paths instead of persisted host snapshots", async () => {
1760+
const { dir, sessionFile } = createSessionFile();
1761+
const hostSkillDir = "/home/tzdai/.npm-global/lib/node_modules/openclaw/skills/gog";
1762+
const hostSkillPath = `${hostSkillDir}/SKILL.md`;
1763+
const materializedWorkspace = path.join(dir, "state", "sandbox-skills");
1764+
const materializedSkillDir = path.join(materializedWorkspace, "skills", "gog");
1765+
const materializedSkillPath = path.join(materializedSkillDir, "SKILL.md");
1766+
fs.mkdirSync(materializedSkillDir, { recursive: true });
1767+
fs.writeFileSync(
1768+
materializedSkillPath,
1769+
[
1770+
"---",
1771+
"name: gog",
1772+
"description: Read Gmail safely.",
1773+
"---",
1774+
"",
1775+
"Use the Gmail tools before answering mail questions.",
1776+
].join("\n"),
1777+
"utf-8",
1778+
);
1779+
ensureSandboxWorkspaceForSessionMock.mockResolvedValue({
1780+
workspaceDir: dir,
1781+
containerWorkdir: "/workspace",
1782+
skillsWorkspaceDir: materializedWorkspace,
1783+
workspaceAccess: "rw",
1784+
});
1785+
1786+
try {
1787+
const context = await prepareCliRunContext({
1788+
sessionId: "session-test",
1789+
sessionKey: "agent:main:sandboxed-user",
1790+
agentId: "main",
1791+
sessionFile,
1792+
workspaceDir: dir,
1793+
prompt: "are there any unread emails",
1794+
provider: "test-cli",
1795+
model: "test-model",
1796+
timeoutMs: 1_000,
1797+
runId: "run-sandbox-cli-skill-prompt",
1798+
config: createCliBackendConfig(),
1799+
skillsSnapshot: {
1800+
prompt: [
1801+
"<available_skills>",
1802+
" <skill>",
1803+
" <name>gog</name>",
1804+
" <description>Read Gmail safely.</description>",
1805+
` <location>${hostSkillPath}</location>`,
1806+
" </skill>",
1807+
"</available_skills>",
1808+
].join("\n"),
1809+
skills: [{ name: "gog" }],
1810+
resolvedSkills: [
1811+
{
1812+
name: "gog",
1813+
description: "Read Gmail safely.",
1814+
filePath: hostSkillPath,
1815+
baseDir: hostSkillDir,
1816+
source: "openclaw-bundled",
1817+
sourceInfo: {
1818+
path: hostSkillPath,
1819+
source: "openclaw-bundled",
1820+
scope: "project",
1821+
origin: "top-level",
1822+
baseDir: hostSkillDir,
1823+
},
1824+
disableModelInvocation: false,
1825+
},
1826+
],
1827+
},
1828+
});
1829+
1830+
expect(ensureSandboxWorkspaceForSessionMock).toHaveBeenCalledWith({
1831+
config: createCliBackendConfig(),
1832+
sessionKey: "agent:main:sandboxed-user",
1833+
workspaceDir: dir,
1834+
});
1835+
expect(context.systemPrompt).toContain(
1836+
"/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md",
1837+
);
1838+
expect(context.systemPrompt).not.toContain(hostSkillPath);
1839+
expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0);
1840+
expect(context.systemPromptReport.skills.entries).toEqual([
1841+
{ name: "gog", blockChars: expect.any(Number) },
1842+
]);
1843+
} finally {
1844+
fs.rmSync(dir, { recursive: true, force: true });
1845+
}
1846+
});
1847+
17481848
it("omits Claude CLI prompt skills when the native skills plugin can carry them", async () => {
17491849
const { dir, sessionFile } = createSessionFile();
17501850
const skillDir = path.join(dir, "skills", "weather");

src/agents/cli-runner/prepare.ts

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-con
2626
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
2727
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
2828
import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js";
29+
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
2930
import { resolveUserPath } from "../../utils.js";
3031
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
3132
import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js";
@@ -63,8 +64,13 @@ import {
6364
} from "../embedded-agent-runner/run/attempt.prompt-helpers.js";
6465
import { composeSystemPromptWithHookContext } from "../embedded-agent-runner/run/attempt.thread-helpers.js";
6566
import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js";
67+
import {
68+
mapSandboxSkillEntriesForPrompt,
69+
resolveSandboxSkillRuntimeInputs,
70+
} from "../embedded-agent-runner/sandbox-skills.js";
6671
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
6772
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
73+
import { ensureSandboxWorkspaceForSession } from "../sandbox.js";
6874
import { ensureSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js";
6975
import { buildSystemPromptReport } from "../system-prompt-report.js";
7076
import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js";
@@ -98,6 +104,75 @@ const prepareDeps = {
98104
claudeCliSessionTranscriptHasOrphanedToolUse,
99105
};
100106

107+
async function resolveCliSkillsPrompt(params: {
108+
agentId: string;
109+
config: RunCliAgentParams["config"];
110+
sessionKey: string;
111+
skillsSnapshot: RunCliAgentParams["skillsSnapshot"];
112+
workspaceDir: string;
113+
}): Promise<string> {
114+
const sandboxWorkspace = await ensureSandboxWorkspaceForSession({
115+
config: params.config,
116+
sessionKey: params.sessionKey,
117+
workspaceDir: params.workspaceDir,
118+
});
119+
if (!sandboxWorkspace) {
120+
return resolveSkillsPromptForRun({
121+
skillsSnapshot: params.skillsSnapshot,
122+
workspaceDir: params.workspaceDir,
123+
config: params.config,
124+
agentId: params.agentId,
125+
});
126+
}
127+
128+
const {
129+
skillsEligibility,
130+
skillsPromptWorkspaceDir,
131+
skillsSnapshot: skillsSnapshotForRun,
132+
skillsWorkspaceDir,
133+
workspaceOnly,
134+
} = resolveSandboxSkillRuntimeInputs({
135+
sandbox: {
136+
enabled: true,
137+
...(sandboxWorkspace.containerWorkdir
138+
? { containerWorkdir: sandboxWorkspace.containerWorkdir }
139+
: {}),
140+
...(sandboxWorkspace.skillsEligibility
141+
? { skillsEligibility: sandboxWorkspace.skillsEligibility }
142+
: {}),
143+
...(sandboxWorkspace.skillsWorkspaceDir
144+
? { skillsWorkspaceDir: sandboxWorkspace.skillsWorkspaceDir }
145+
: {}),
146+
...(sandboxWorkspace.workspaceAccess
147+
? { workspaceAccess: sandboxWorkspace.workspaceAccess }
148+
: {}),
149+
},
150+
effectiveWorkspace: sandboxWorkspace.workspaceDir,
151+
skillsSnapshot: params.skillsSnapshot,
152+
});
153+
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
154+
workspaceDir: skillsWorkspaceDir,
155+
config: params.config,
156+
agentId: params.agentId,
157+
eligibility: skillsEligibility,
158+
skillsSnapshot: skillsSnapshotForRun,
159+
workspaceOnly,
160+
});
161+
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
162+
entries: shouldLoadSkillEntries ? skillEntries : undefined,
163+
skillsWorkspaceDir,
164+
skillsPromptWorkspaceDir,
165+
});
166+
return resolveSkillsPromptForRun({
167+
skillsSnapshot: skillsSnapshotForRun,
168+
entries: promptSkillEntries,
169+
workspaceDir: skillsPromptWorkspaceDir,
170+
config: params.config,
171+
agentId: params.agentId,
172+
eligibility: skillsEligibility,
173+
});
174+
}
175+
101176
const CLAUDE_CLI_CONTEXT_MODEL_ALIASES: Record<string, string> = {
102177
opus: "claude-opus-4-8",
103178
"opus-4.8": "claude-opus-4-8",
@@ -450,13 +525,16 @@ export async function prepareCliRunContext(
450525
cwd,
451526
moduleUrl: import.meta.url,
452527
});
453-
const skillsPrompt = resolveSkillsPromptForRun({
454-
skillsSnapshot: params.skillsSnapshot,
455-
workspaceDir,
456-
config: params.config,
457-
agentId: sessionAgentId,
458-
});
459-
const systemPromptSkillsPrompt = claudeSkillsPlugin.args.length > 0 ? "" : skillsPrompt;
528+
const systemPromptSkillsPrompt =
529+
claudeSkillsPlugin.args.length > 0
530+
? ""
531+
: await resolveCliSkillsPrompt({
532+
skillsSnapshot: params.skillsSnapshot,
533+
workspaceDir,
534+
config: params.config,
535+
agentId: sessionAgentId,
536+
sessionKey: params.sessionKey?.trim() || params.sessionId,
537+
});
460538
const builtSystemPrompt = buildCliAgentSystemPrompt({
461539
workspaceDir,
462540
cwd,

0 commit comments

Comments
 (0)