From e09708e82d03e5d13ba7aec0bd1d11c8a21c2c9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 00:30:51 +0100 Subject: [PATCH] feat: sync skills into sandbox workspace --- docs/gateway/sandboxing.md | 4 +++ src/agents/sandbox.ts | 27 +++++++++++++++++++ src/agents/skills.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ src/agents/skills.ts | 37 +++++++++++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index a064d72d1..7f2e6ba24 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -45,6 +45,10 @@ Not sandboxed: - `"rw"`: mounts the agent workspace read/write at `/workspace`. Inbound media is copied into the active sandbox workspace (`media/inbound/*`). +Skills note: the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, +Clawdbot mirrors eligible skills into the sandbox workspace (`.../skills`) so +they can be read. With `"rw"`, workspace skills are readable from +`/workspace/skills`. ## Images + setup Default image: `clawdbot-sandbox:bookworm-slim` diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 4792c6379..53cc9c1c8 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -19,6 +19,7 @@ import { STATE_DIR_CLAWDBOT } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; import { resolveAgentIdFromSessionKey } from "./agent-scope.js"; +import { syncSkillsToWorkspace } from "./skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENTS_FILENAME, @@ -1048,6 +1049,19 @@ export async function resolveSandboxContext(params: { agentWorkspaceDir, params.config?.agent?.skipBootstrap, ); + if (cfg.workspaceAccess === "none") { + try { + await syncSkillsToWorkspace({ + sourceWorkspaceDir: agentWorkspaceDir, + targetWorkspaceDir: sandboxWorkspaceDir, + config: params.config, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`); + } + } } else { await fs.mkdir(workspaceDir, { recursive: true }); } @@ -1109,6 +1123,19 @@ export async function ensureSandboxWorkspaceForSession(params: { agentWorkspaceDir, params.config?.agent?.skipBootstrap, ); + if (cfg.workspaceAccess === "none") { + try { + await syncSkillsToWorkspace({ + sourceWorkspaceDir: agentWorkspaceDir, + targetWorkspaceDir: sandboxWorkspaceDir, + config: params.config, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`); + } + } } else { await fs.mkdir(workspaceDir, { recursive: true }); } diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 9178bf971..b1ec8b60c 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -10,6 +10,7 @@ import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt, loadWorkspaceSkillEntries, + syncSkillsToWorkspace, } from "./skills.js"; import { buildWorkspaceSkillStatus } from "./skills-status.js"; @@ -130,6 +131,60 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(prompt).toContain(path.join(skillDir, "SKILL.md")); }); + it("syncs merged skills into a target workspace", async () => { + const sourceWorkspace = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-"), + ); + const targetWorkspace = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-"), + ); + const extraDir = path.join(sourceWorkspace, ".extra"); + const bundledDir = path.join(sourceWorkspace, ".bundled"); + const managedDir = path.join(sourceWorkspace, ".managed"); + + await writeSkill({ + dir: path.join(extraDir, "demo-skill"), + name: "demo-skill", + description: "Extra version", + }); + await writeSkill({ + dir: path.join(bundledDir, "demo-skill"), + name: "demo-skill", + description: "Bundled version", + }); + await writeSkill({ + dir: path.join(managedDir, "demo-skill"), + name: "demo-skill", + description: "Managed version", + }); + await writeSkill({ + dir: path.join(sourceWorkspace, "skills", "demo-skill"), + name: "demo-skill", + description: "Workspace version", + }); + + await syncSkillsToWorkspace({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + config: { skills: { load: { extraDirs: [extraDir] } } }, + bundledSkillsDir: bundledDir, + managedSkillsDir: managedDir, + }); + + const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, { + bundledSkillsDir: path.join(targetWorkspace, ".bundled"), + managedSkillsDir: path.join(targetWorkspace, ".managed"), + }); + + expect(prompt).toContain("Workspace version"); + expect(prompt).not.toContain("Managed version"); + expect(prompt).not.toContain("Bundled version"); + expect(prompt).not.toContain("Extra version"); + expect(prompt).toContain( + path.join(targetWorkspace, "skills", "demo-skill", "SKILL.md"), + ); + }); + it("filters skills based on env/config gates", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro"); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index e323da6b9..405fd6beb 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -11,6 +11,8 @@ import { import type { ClawdbotConfig, SkillConfig } from "../config/config.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +const fsp = fs.promises; + export type SkillInstallSpec = { id?: string; kind: "brew" | "node" | "go" | "uv"; @@ -619,6 +621,41 @@ export function loadWorkspaceSkillEntries( return loadSkillEntries(workspaceDir, opts); } +export async function syncSkillsToWorkspace(params: { + sourceWorkspaceDir: string; + targetWorkspaceDir: string; + config?: ClawdbotConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; +}) { + const sourceDir = resolveUserPath(params.sourceWorkspaceDir); + const targetDir = resolveUserPath(params.targetWorkspaceDir); + if (sourceDir === targetDir) return; + const targetSkillsDir = path.join(targetDir, "skills"); + + const entries = loadSkillEntries(sourceDir, { + config: params.config, + managedSkillsDir: params.managedSkillsDir, + bundledSkillsDir: params.bundledSkillsDir, + }); + + await fsp.rm(targetSkillsDir, { recursive: true, force: true }); + await fsp.mkdir(targetSkillsDir, { recursive: true }); + + for (const entry of entries) { + const dest = path.join(targetSkillsDir, entry.skill.name); + try { + await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + console.warn( + `[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`, + ); + } + } +} + export function filterWorkspaceSkillEntries( entries: SkillEntry[], config?: ClawdbotConfig,