Skip to content

Commit 801d812

Browse files
committed
fix(sandbox): use materialized skill paths in command prompts
1 parent a4e02cd commit 801d812

7 files changed

Lines changed: 293 additions & 45 deletions

File tree

src/agents/embedded-agent-runner/sandbox-skills.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("resolveSandboxSkillRuntimeInputs", () => {
9696
});
9797

9898
it("rebuilds sandbox prompts from materialized skill paths", async () => {
99-
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
99+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
100100
try {
101101
const effectiveWorkspace = path.join(root, "workspace");
102102
const materializedWorkspace = path.join(root, "state", "sandbox-skills");
@@ -160,7 +160,9 @@ describe("resolveSandboxSkillRuntimeInputs", () => {
160160
});
161161

162162
expect(prompt).toContain("/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md");
163-
expect(prompt.replaceAll("\\", "/")).not.toContain(materializedWorkspace.replaceAll("\\", "/"));
163+
expect(prompt.replaceAll("\\", "/")).not.toContain(
164+
materializedWorkspace.replaceAll("\\", "/"),
165+
);
164166
expect(prompt).not.toContain(hostSkillPath);
165167
expect(prompt).not.toContain("plugin-skills");
166168
expect(prompt.replaceAll("\\", "/")).not.toContain("/skills/canvas/SKILL.md");

src/agents/sandbox.resolveSandboxContext.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,50 @@ describe("resolveSandboxContext", () => {
409409
expect(syncOptions?.config).toBe(cfg);
410410
expect(syncOptions?.agentId).toBe("main");
411411
expect(syncOptions?.eligibility).toEqual({ remote: { note: "test-remote" } });
412+
expect(result?.skillsWorkspaceDir).toBe(syncOptions?.targetWorkspaceDir);
413+
expect(result?.workspaceAccess).toBe("rw");
414+
expect(result?.skillsEligibility).toEqual({ remote: { note: "test-remote" } });
412415
await expect(
413416
fs.readFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "utf8"),
414417
).resolves.toBe("# User owned\n");
415418
}, 15_000);
416419

420+
it("uses the SSH backend remote workspace for sandbox workspace info", async () => {
421+
syncSkillsToWorkspaceMock.mockClear();
422+
const workspaceDir = await createSandboxFixtureDir("ssh-workspace");
423+
const cfg: OpenClawConfig = {
424+
agents: {
425+
defaults: {
426+
sandbox: {
427+
mode: "all",
428+
backend: "ssh",
429+
scope: "session",
430+
workspaceAccess: "rw",
431+
ssh: {
432+
target: "ssh.example.test",
433+
workspaceRoot: "/remote/openclaw",
434+
},
435+
},
436+
},
437+
},
438+
};
439+
440+
const result = await ensureSandboxWorkspaceForSession({
441+
config: cfg,
442+
sessionKey: "agent:main:main",
443+
workspaceDir,
444+
});
445+
446+
expect(result?.workspaceDir).toBe(workspaceDir);
447+
expect(result?.containerWorkdir).toMatch(
448+
/^\/remote\/openclaw\/openclaw-ssh-agent-main-main-[a-f0-9]{8}\/workspace$/,
449+
);
450+
expect(result?.containerWorkdir).not.toBe("/workspace");
451+
expect(result?.skillsWorkspaceDir).toContain(
452+
path.join(".openclaw", "sandbox", "skills-workspaces"),
453+
);
454+
}, 15_000);
455+
417456
it("materializes skills for shared writable sandboxes even when roots match", async () => {
418457
syncSkillsToWorkspaceMock.mockClear();
419458
const workspaceDir = await createSandboxFixtureDir("shared-workspace");

src/agents/sandbox/context.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { createSandboxFsBridge } from "./fs-bridge.js";
2626
import { updateRegistry } from "./registry.js";
2727
import { resolveSandboxRuntimeStatus } from "./runtime-status.js";
2828
import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js";
29+
import { resolveSshRuntimePaths } from "./ssh-backend.js";
2930
import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js";
3031
import { resolveMaterializedSandboxSkillsWorkspaceDir } from "./workspace-mounts.js";
3132
import { ensureSandboxWorkspace } from "./workspace.js";
@@ -178,6 +179,16 @@ function resolveSandboxSession(params: { config?: OpenClawConfig; sessionKey?: s
178179
return { rawSessionKey, runtime, cfg };
179180
}
180181

182+
function resolveSandboxWorkspaceInfoWorkdir(params: {
183+
cfg: ReturnType<typeof resolveSandboxConfigForAgent>;
184+
scopeKey: string;
185+
}): string {
186+
if (params.cfg.backend === "ssh") {
187+
return resolveSshRuntimePaths(params.cfg.ssh.workspaceRoot, params.scopeKey).remoteWorkspaceDir;
188+
}
189+
return params.cfg.docker.workdir;
190+
}
191+
181192
export async function resolveSandboxContext(params: {
182193
config?: OpenClawConfig;
183194
sessionKey?: string;
@@ -308,16 +319,20 @@ export async function ensureSandboxWorkspaceForSession(params: {
308319
}
309320
const { rawSessionKey, cfg, runtime } = resolved;
310321

311-
const { workspaceDir } = await ensureSandboxWorkspaceLayout({
312-
cfg,
313-
agentId: runtime.agentId,
314-
rawSessionKey,
315-
config: params.config,
316-
workspaceDir: params.workspaceDir,
317-
});
322+
const { scopeKey, skillsEligibility, skillsWorkspaceDir, workspaceDir } =
323+
await ensureSandboxWorkspaceLayout({
324+
cfg,
325+
agentId: runtime.agentId,
326+
rawSessionKey,
327+
config: params.config,
328+
workspaceDir: params.workspaceDir,
329+
});
318330

319331
return {
320332
workspaceDir,
321-
containerWorkdir: cfg.docker.workdir,
333+
containerWorkdir: resolveSandboxWorkspaceInfoWorkdir({ cfg, scopeKey }),
334+
skillsWorkspaceDir,
335+
...(skillsEligibility ? { skillsEligibility } : {}),
336+
workspaceAccess: cfg.workspaceAccess,
322337
};
323338
}

src/agents/sandbox/ssh-backend.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,10 @@ async function isExistingDirectory(dir: string): Promise<boolean> {
333333
}
334334
}
335335

336-
function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths {
336+
export function resolveSshRuntimePaths(
337+
workspaceRoot: string,
338+
scopeKey: string,
339+
): ResolvedSshRuntimePaths {
337340
const runtimeId = buildSshSandboxRuntimeId(scopeKey);
338341
const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId);
339342
return {

src/agents/sandbox/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SkillEligibilityContext } from "../../skills/types.js";
12
/**
23
* Sandbox runtime configuration and context types.
34
*
@@ -6,7 +7,6 @@
67
import type { SandboxBackendHandle, SandboxBackendId } from "./backend-handle.types.js";
78
import type { SandboxFsBridge } from "./fs-bridge.types.js";
89
import type { SandboxDockerConfig } from "./types.docker.js";
9-
import type { SkillEligibilityContext } from "../../skills/types.js";
1010

1111
export type { SandboxDockerConfig } from "./types.docker.js";
1212

@@ -116,4 +116,7 @@ export type SandboxContext = {
116116
export type SandboxWorkspaceInfo = {
117117
workspaceDir: string;
118118
containerWorkdir: string;
119+
skillsWorkspaceDir?: string;
120+
skillsEligibility?: SkillEligibilityContext;
121+
workspaceAccess?: SandboxWorkspaceAccess;
119122
};

src/auto-reply/reply/commands-system-prompt.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
// Tests system prompt command output and bundled prompt section selection.
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { beforeEach, describe, expect, it, vi } from "vitest";
36
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
47
import { createOpenClawCodingTools } from "../../agents/agent-tools.js";
58
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
6-
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
9+
import {
10+
ensureSandboxWorkspaceForSession,
11+
resolveSandboxRuntimeStatus,
12+
} from "../../agents/sandbox.js";
713
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
14+
import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js";
815
import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js";
916
import type { HandleCommandsParams } from "./commands-types.js";
1017

@@ -20,6 +27,7 @@ vi.mock("../../agents/bootstrap-files.js", () => ({
2027
}));
2128

2229
vi.mock("../../agents/sandbox.js", () => ({
30+
ensureSandboxWorkspaceForSession: vi.fn(async () => null),
2331
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false, mode: "off" })),
2432
}));
2533

@@ -130,6 +138,12 @@ describe("resolveCommandsSystemPromptBundle", () => {
130138
vi.clearAllMocks();
131139
createOpenClawCodingToolsMock.mockClear();
132140
createOpenClawCodingToolsMock.mockReturnValue([]);
141+
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue(null);
142+
vi.mocked(resolveReusableWorkspaceSkillSnapshot).mockReturnValue({
143+
snapshot: { prompt: "", skills: [], resolvedSkills: [] },
144+
shouldRefresh: false,
145+
snapshotVersion: "test-snapshot",
146+
} as never);
133147
});
134148

135149
it("opts command tool builds into gateway subagent binding", async () => {
@@ -254,6 +268,69 @@ describe("resolveCommandsSystemPromptBundle", () => {
254268
expect(sandboxInfo?.elevated?.fullAccessBlockedReason).toBe("host-policy");
255269
});
256270

271+
it("uses materialized sandbox skill paths for sandbox command prompts", async () => {
272+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-sandbox-skills-"));
273+
try {
274+
const workspaceDir = path.join(root, "workspace");
275+
const skillsWorkspaceDir = path.join(root, "state", "sandbox-skills");
276+
const skillDir = path.join(skillsWorkspaceDir, "skills", "gog");
277+
await fs.mkdir(skillDir, { recursive: true });
278+
await fs.writeFile(
279+
path.join(skillDir, "SKILL.md"),
280+
["---", "name: gog", "description: Gog skill", "---", "# Gog", ""].join("\n"),
281+
"utf8",
282+
);
283+
const params = makeParams();
284+
params.workspaceDir = workspaceDir;
285+
vi.mocked(resolveSandboxRuntimeStatus).mockReturnValue({
286+
sandboxed: true,
287+
mode: "workspace-write",
288+
} as never);
289+
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
290+
workspaceDir,
291+
containerWorkdir: "/workspace",
292+
skillsWorkspaceDir,
293+
skillsEligibility: {
294+
remote: {
295+
platforms: ["linux"],
296+
hasBin: () => true,
297+
hasAnyBin: () => true,
298+
note: "sandbox",
299+
},
300+
},
301+
workspaceAccess: "rw",
302+
} as never);
303+
vi.mocked(resolveReusableWorkspaceSkillSnapshot).mockReturnValue({
304+
snapshot: {
305+
prompt:
306+
"<available_skills>~/.npm-global/lib/node_modules/openclaw/skills/gog/SKILL.md</available_skills>",
307+
skills: [],
308+
resolvedSkills: [],
309+
},
310+
shouldRefresh: false,
311+
snapshotVersion: "host-snapshot",
312+
} as never);
313+
314+
const result = await resolveCommandsSystemPromptBundle(params);
315+
316+
expect(result.skillsPrompt).toContain(
317+
"/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md",
318+
);
319+
expect(result.skillsPrompt).not.toContain("~/.npm-global");
320+
expect(vi.mocked(resolveReusableWorkspaceSkillSnapshot)).not.toHaveBeenCalled();
321+
const promptParams = requireFirstArg(
322+
vi.mocked(buildAgentSystemPrompt),
323+
"buildAgentSystemPrompt",
324+
);
325+
expect(promptParams.skillsPrompt).toContain(
326+
"/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md",
327+
);
328+
expect(String(promptParams.skillsPrompt)).not.toContain("~/.npm-global");
329+
} finally {
330+
await fs.rm(root, { recursive: true, force: true });
331+
}
332+
});
333+
257334
it("uses config-backed prompt settings for the target agent", async () => {
258335
vi.mocked(resolveSandboxRuntimeStatus).mockReturnValue({
259336
sandboxed: false,

0 commit comments

Comments
 (0)